From 3487bdaec89446dcebadfb81d304b0e1172cc20c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 23:04:28 +0200 Subject: [PATCH] feat(coach): fold the derived wellness layer into the snapshot as compact summaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "derived" block to the Coach snapshot: one tiny object per high-signal derived metric (readiness, recovery, sleep, stress, strain, HRV balance, cardio-fitness band, vascular-age delta) carrying value + band + confidence + historyDays — never the raw series. A fully-populated block is ~250 tokens, comfortably inside the snapshot budget. Insufficient metrics are omitted so the model never sees no-data noise; each metric's compute is fault-isolated so a transient failure on one never sinks the turn. Gated on the underlying signals staying in scope. The system prompt now documents the block and frames the bands as descriptive composites, not clinical findings. --- .../coach/__tests__/derived-snapshot.test.ts | 75 ++++++++++ src/lib/ai/coach/derived-snapshot.ts | 128 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 41 ++++++ src/lib/ai/coach/system-prompt.ts | 11 ++ 4 files changed, 255 insertions(+) create mode 100644 src/lib/ai/coach/__tests__/derived-snapshot.test.ts create mode 100644 src/lib/ai/coach/derived-snapshot.ts 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