Files
HealthLog/src/lib/ai/coach/trajectory-snapshot.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

114 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.11.0 (Epic B, Pillar 3) — short-horizon trajectory block for the
* Coach prompt.
*
* Folds the deterministic `computeTrajectory` forecasting engine into the
* Coach snapshot as COMPACT per-metric projections: direction + slope +
* the projected END of the horizon with its widening prediction band, the
* last observed value, and the fit's R² / confidence. The Coach narrates
* the SAME numbers the engine computed (single source of truth) — it never
* invents a forecast; the projection is computed off-LLM and the model
* only describes a band that is already here, and only when present.
*
* Every entry reads the one `computeTrajectory` contract — NEVER a
* recompute. A metric whose projection is `insufficient` (below the
* R²/history/staleness floor) is OMITTED entirely; the block is omitted
* when none of the in-scope metrics resolve to `ok`, so the Coach prompt's
* conditional-projection ground rule has nothing to narrate and (per that
* rule) does not project.
*
* Server-only — calls the trajectory engine, which reads the rollup tier +
* raw rows via the baseline reader.
*/
import {
computeTrajectory,
isDerivedOk,
TRAJECTORY_TYPES,
type BaselineProfile,
type TrajectoryValue,
} from "@/lib/insights/derived";
/** One compact projection the Coach can ground a conditional reply in. */
interface TrajectorySnapshotEntry {
/** Trend direction over the fit window. */
direction: "up" | "down" | "stable";
/** OLS slope in metric units per day. */
slopePerDay: number;
/** Horizon the projection covers (days). */
horizonDays: number;
/** Last observed per-day mean — the fan's anchor. */
lastValue: number;
/** The horizon END point: the projected value + its widening band. */
projectedEnd: {
value: number;
bandLow: number;
bandHigh: number;
};
/** R² of the fit (0..1) — the confidence the band rides. */
r2: number;
/** Server-computed confidence 0–100 (never the model's self-confidence). */
confidence: number;
}
/** Round a metric value to one decimal — compact, no false precision. */
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
/** Pull the compact entry off a successful trajectory value. */
function summariseTrajectory(
value: TrajectoryValue,
confidence: number,
): TrajectorySnapshotEntry | null {
const end = value.projection[value.projection.length - 1];
if (!end) return null;
return {
direction: value.direction,
slopePerDay: round1(value.slopePerDay),
horizonDays: value.horizonDays,
lastValue: round1(value.lastValue),
projectedEnd: {
value: round1(end.projected),
bandLow: round1(end.bandLow),
bandHigh: round1(end.bandHigh),
},
r2: Math.round(value.r2 * 100) / 100,
confidence,
};
}
/**
* Build the compact trajectory block, or `null` when no in-scope metric
* resolved to a projection. Each entry carries direction + slope +
* projected horizon-end-with-band + R² + confidence — the model never sees
* the full fan or the raw series. Computes sequentially off the one shared
* profile (the trajectory engine probes rollup coverage per call).
*/
export async function buildTrajectorySnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
): Promise<Record<string, TrajectorySnapshotEntry> | null> {
const block: Record<string, TrajectorySnapshotEntry> = {};
for (const type of TRAJECTORY_TYPES) {
// Per-metric fault isolation: a transient compute failure on one metric
// must never sink the whole Coach turn — drop it and carry on.
let derived;
try {
derived = await computeTrajectory(userId, profile, { type, now });
} catch {
continue;
}
if (!isDerivedOk(derived)) continue; // omit insufficient — no projection noise
const summary = summariseTrajectory(
derived.value,
derived.confidence.score,
);
if (!summary) continue;
block[type] = summary;
}
return Object.keys(block).length > 0 ? block : null;
}