feat(coach): fold the derived wellness layer into the snapshot as compact summaries

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 23:13:26 +02:00
1 parent d9405e17b9
commit 3487bdaec8
4 files changed
+255

No files matched your search

@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/insights/derived")>();
return { ...actual, computeDerivedMetric: vi.fn() };
});
import { computeDerivedMetric } from "@/lib/insights/derived";
import { buildDerivedSnapshotBlock } from "../derived-snapshot";
const compute = computeDerivedMetric as unknown as ReturnType<typeof vi.fn>;
const PROFILE = { ageYears: 40, sex: "MALE" as const, heightCm: 180 };
const NOW = new Date("2026-06-02T08:00:00Z");
function ok(value: Record<string, unknown>, 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 },
});
});
});
+128
View File
@@ -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<string, unknown>;
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<Record<string, DerivedSnapshotEntry> | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
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;
}
+41
View File
@@ -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");
}
+11
View File
@@ -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