diff --git a/src/lib/ai/coach/__tests__/derived-snapshot.test.ts b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts new file mode 100644 index 000000000..a43175f29 --- /dev/null +++ b/src/lib/ai/coach/__tests__/derived-snapshot.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/insights/derived", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, computeDerivedMetric: vi.fn() }; +}); + +import { computeDerivedMetric } from "@/lib/insights/derived"; +import { buildDerivedSnapshotBlock } from "../derived-snapshot"; + +const compute = computeDerivedMetric as unknown as ReturnType; +const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 }; +const NOW = new Date("2026-06-02T08:00:00Z"); + +function ok(value: Record, historyDays = 14) { + return { + status: "ok" as const, + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays, missing: [] }, + confidence: { score: 88, band: "high" as const }, + provenance: { inputs: [], source: "DAY" as const, windowDays: 14, computedAt: "x" }, + }; +} +const insufficient = { + status: "insufficient" as const, + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] }, + provenance: { inputs: [], source: "none" as const, windowDays: 0, computedAt: "x" }, + reason: "no_score_in_window", +}; + +beforeEach(() => compute.mockReset()); + +describe("buildDerivedSnapshotBlock", () => { + it("returns null when every metric is insufficient", async () => { + compute.mockResolvedValue(insufficient); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block).toBeNull(); + }); + + it("emits compact value + band + confidence per ok metric, omits insufficient", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") return ok({ score: 64, band: "yellow" }); + if (args?.metric === "RECOVERY_SCORE") return ok({ score: 72, band: "green" }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block).not.toBeNull(); + expect(Object.keys(block!).sort()).toEqual(["READINESS", "RECOVERY_SCORE"]); + expect(block!.READINESS).toEqual({ + value: 64, + band: "yellow", + confidence: 88, + historyDays: 14, + }); + // No raw series leaked — only the four compact facets. + expect(Object.keys(block!.READINESS).sort()).toEqual([ + "band", + "confidence", + "historyDays", + "value", + ]); + }); + + it("isolates a per-metric compute failure", async () => { + compute.mockImplementation(async (args?: { metric?: string }) => { + if (args?.metric === "READINESS") throw new Error("boom"); + if (args?.metric === "SLEEP_SCORE") return ok({ score: 80, band: "green" }); + return insufficient; + }); + const block = await buildDerivedSnapshotBlock("u1", PROFILE, NOW); + expect(block).toEqual({ + SLEEP_SCORE: { value: 80, band: "green", confidence: 88, historyDays: 14 }, + }); + }); +}); diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts new file mode 100644 index 000000000..dc78d673d --- /dev/null +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -0,0 +1,128 @@ +/** + * v1.10.0 — derived-metrics block for the Coach prompt. + * + * Folds the v1.10 derived wellness layer into the Coach snapshot as + * COMPACT summaries — one small object per metric carrying value + band + + * coverage, never the raw series. The Coach narrates the same numbers the + * dashboard rings render (single source of truth) without ballooning the + * ~6k-token snapshot budget: a fully-populated block is ~8 tiny objects. + * + * Every entry reads the one `computeDerivedMetric` contract — no recompute. + * A metric whose value is `insufficient` is OMITTED entirely (we never feed + * the model "no data" noise; the snapshot's compactSections pass would drop + * an empty key anyway). + * + * Server-only — calls the derived dispatcher, which reads `@/lib/db`. + */ +import { + computeDerivedMetric, + isDerivedOk, + type DerivedMetricId, + type BaselineProfile, +} from "@/lib/insights/derived"; + +/** The high-signal derived metrics worth a Coach prompt slot. The vitals + * baseline is omitted — the per-vital aggregate block already carries those + * numbers; here we add the composites + scores the aggregates can't express. */ +const SNAPSHOT_METRICS: DerivedMetricId[] = [ + "READINESS", + "RECOVERY_SCORE", + "STRESS_SCORE", + "STRAIN_SCORE", + "SLEEP_SCORE", + "HRV_BALANCE", + "FITNESS_AGE", + "VASCULAR_AGE_DELTA", +]; + +/** One compact line per derived metric the model can ground a reply in. */ +interface DerivedSnapshotEntry { + /** 0–100 score, the recent average, or the re-framed device value. */ + value: number; + /** The metric's band/category in plain words (green/yellow/red, etc.). */ + band?: string; + /** Confidence 0–100 from the coverage model. */ + confidence?: number; + /** Days of history backing the value. */ + historyDays: number; +} + +/** Pull the headline number + band off each metric's value shape. */ +function summariseValue( + metric: DerivedMetricId, + value: unknown, +): { value: number; band?: string } | null { + const v = value as Record; + switch (metric) { + case "READINESS": + case "SLEEP_SCORE": + case "RECOVERY_SCORE": + case "STRESS_SCORE": + case "STRAIN_SCORE": + return typeof v.score === "number" + ? { value: v.score, band: typeof v.band === "string" ? v.band : undefined } + : null; + case "HRV_BALANCE": + return typeof v.recentAvg === "number" + ? { value: Math.round(v.recentAvg), band: typeof v.band === "string" ? v.band : undefined } + : null; + case "FITNESS_AGE": + return typeof v.vo2Max === "number" + ? { + value: Math.round(v.vo2Max * 10) / 10, + band: + typeof v.fitnessAgeDeltaYears === "number" + ? `${v.fitnessAgeDeltaYears >= 0 ? "+" : ""}${v.fitnessAgeDeltaYears}yr vs age` + : undefined, + } + : null; + case "VASCULAR_AGE_DELTA": + return typeof v.vascularAge === "number" + ? { + value: Math.round(v.vascularAge), + band: + typeof v.deltaYears === "number" + ? `${v.deltaYears >= 0 ? "+" : ""}${Math.round(v.deltaYears)}yr vs age` + : undefined, + } + : null; + default: + return null; + } +} + +/** + * Build the compact derived block, or `null` when no derived metric resolved + * to `ok`. Each entry is value + band + confidence + historyDays — the model + * never sees the underlying series. Computes the metrics sequentially off the + * one shared profile (no per-metric profile re-read). + */ +export async function buildDerivedSnapshotBlock( + userId: string, + profile: BaselineProfile, + now: Date, +): Promise | null> { + const block: Record = {}; + + for (const metric of SNAPSHOT_METRICS) { + // 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 computeDerivedMetric({ metric, userId, profile, now }); + } catch { + continue; + } + if (!isDerivedOk(derived)) continue; // omit insufficient — no noise + const summary = summariseValue(metric, derived.value); + if (!summary) continue; + block[metric] = { + value: summary.value, + band: summary.band, + confidence: derived.confidence.score, + historyDays: derived.coverage.historyDays, + }; + } + + return Object.keys(block).length > 0 ? block : null; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 0f5dcfcaf..7fdcf7867 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -26,6 +26,8 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; +import { buildDerivedSnapshotBlock } from "./derived-snapshot"; +import type { BaselineProfile } from "@/lib/insights/derived"; import { CLUSTER_PRIORITY, clusterSourcesFromPrefs, @@ -1185,6 +1187,45 @@ async function buildCoachSnapshotImpl( } } + // ── v1.10.0 — derived wellness layer (compact summaries) ───────────── + // + // The composites + persisted scores the dashboard rings render, folded + // in as one tiny object per metric (value + band + coverage), NOT the + // raw series. So the Coach can say "your readiness is 64, low band" and + // ground it in the same number the user sees. Insufficient metrics are + // omitted (no "no data" noise). Reads the same `computeDerivedMetric` + // contract every surface uses — no recompute. Gated on at least one of + // the signals the composites are built from staying in-scope (HRV / + // resting HR / sleep / VO₂max), so a user who excludes those doesn't see + // the block. + const derivedSources: CoachScopeSource[] = [ + "hrv", + "resting_hr", + "sleep", + "vo2_max", + ]; + if (derivedSources.some((s) => sources.has(s))) { + const ctx = features.context; + const derivedProfile: BaselineProfile = { + ageYears: ctx?.ageYears ?? null, + sex: + ctx?.gender === "MALE" || ctx?.gender === "FEMALE" + ? (ctx.gender as "MALE" | "FEMALE") + : null, + heightCm: ctx?.heightCm ?? null, + }; + const derivedBlock = await buildDerivedSnapshotBlock( + userId, + derivedProfile, + now, + ); + if (derivedBlock) { + snapshot.derived = derivedBlock; + metrics.add("hrv"); + registerBlock("derived", "hrv"); + } + } + if (Object.keys(snapshot).length === 0) { metrics.add("general"); } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 4499ab8f2..35253bffe 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -178,6 +178,17 @@ ISO-week means. cluster the SNAPSHOT does not contain. The "workouts" block carries the recent sessions plus a per-sport rollup, not every session. +- The SNAPSHOT MAY carry a "derived" block: compact wellness summaries + the app already computed (readiness, recovery, sleep score, stress, + strain, HRV balance, cardio-fitness band, vascular-age delta). Each + entry is { value, band, confidence, historyDays } — a single number + plus its band ("green"/"yellow"/"red") and how many days back it. + Cite these as the user sees them ("your readiness is 64, in the + yellow band") to ground a reply. These are DESCRIPTIVE composites, + not clinical assessments or diagnoses — never frame a band as a + medical finding, and lean on lower-confidence / few-day entries + cautiously. Never recompute or second-guess the number. + EVIDENCE BLOCK After your prose reply, append the load-bearing numbers as a sentinel