diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 812c38b80..93311440c 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -393,9 +393,18 @@ The user's message answers this clarifying question from their self-context ques React briefly and personally to the answer; do not repeat the question and do not ask it again. ` : ""; + // v1.18.6 (W7) — citation-aware reference-range grounding for the metrics + // present in this snapshot. Deterministic + brand-free; framed as general + // guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully + // inspectable and the model reads the published population bands + the + // user's placement. Omitted entirely when no present metric is covered by + // the reference backbone (the builder returns null). + const groundingBlock = snapshot.referenceGrounding + ? `\n${snapshot.referenceGrounding}\n` + : ""; const userPrompt = `SNAPSHOT ${snapshot.snapshotJson || "(no metric data in this user's log yet)"} -${guidedBlock} +${groundingBlock}${guidedBlock} CONVERSATION ${transcript} diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts new file mode 100644 index 000000000..c04540a83 --- /dev/null +++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import { buildReferenceGroundingBlock } from "../reference-grounding"; +import { CITATIONS } from "@/lib/medical-citations"; + +/** + * W7 — the Coach reference-grounding block. These tests pin the + * deterministic shape the hallucination-QA pass audits: the right band per + * metric, the right four-state placement, the general-guidance caveat, the + * ESH-2023 blood-pressure label, the diabetes opt-in gating, and the hard + * no-commercial-brand rule. + */ + +/** + * Every commercial brand the Coach prompt already names elsewhere (the + * GLP-1 block / dose-deferral rule). The grounding block must contain NONE + * of them — it is brand-free by construction, and this list is the guard. + */ +const BRAND_TOKENS = [ + "Mounjaro", + "Ozempic", + "Wegovy", + "Zepbound", + "Trulicity", + "Saxenda", + "Rybelsus", + "Eli Lilly", + "Novo Nordisk", + "Withings", + "WHOOP", + "Oura", + "Fitbit", + "Garmin", + "Apple", + "Polar", + "Nightscout", + "Dexcom", + "Libre", +]; + +describe("buildReferenceGroundingBlock", () => { + it("returns null when no metric is covered by the backbone", () => { + expect(buildReferenceGroundingBlock({ metrics: [], hasDiabetes: false })).toBeNull(); + }); + + it("cites the ESH 2023 source for blood pressure and never the US/ESC line", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_PRESSURE", value: 118 }], + hasDiabetes: false, + }); + expect(block).not.toBeNull(); + expect(block).toContain("ESH 2023"); + // The headline normal band edge (≤120 mmHg) is surfaced. + expect(block).toContain("120 mmHg"); + // The conflicting US/ESC framings stay out of the per-line copy; only + // the preamble's neutral "(ACC/AHA)" context mentions them. + expect(block).not.toContain("Stage 1"); + // 118 sits inside the optimal band → "within" placement phrasing. + expect(block).toContain("sits inside the general reference band"); + }); + + it("places an out-of-band value as outside", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_PRESSURE", value: 165 }], + hasDiabetes: false, + }); + expect(block).toContain("sits outside the general reference band"); + }); + + it("opens and closes with a general-guidance, not-a-diagnosis caveat", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BMI", value: 22 }], + hasDiabetes: false, + })!; + expect(block).toContain("general guidance"); + expect(block).toContain("not a diagnosis"); + expect(block.toLowerCase()).toContain( + "not personal medical advice", + ); + // The closing reminder line is present. + expect(block).toMatch(/general guidance, not personal medical advice\.$/); + }); + + it("grounds glucose against the general non-diabetic band when the opt-in is off", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }], + hasDiabetes: false, + })!; + expect(block).toContain("general non-diabetic normal"); + // The non-diabetic fasting normal ceiling is 100 mg/dL. + expect(block).toContain("100 mg/dL"); + expect(block).not.toContain("80–130"); + expect(block).toContain("sits inside the general reference band"); + }); + + it("grounds glucose against the tighter ADA goal band when diabetes is opted in", () => { + const inGoal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 110 }], + hasDiabetes: true, + })!; + // The ADA diabetic fasting goal band, framed as a clinician-set goal. + expect(inGoal).toContain("80–130 mg/dL"); + expect(inGoal).toContain("management goal"); + expect(inGoal).toContain("clinician-set goal"); + // The line explicitly frames the band as a goal, NOT a screening line. + expect(inGoal).toContain("not a screening line"); + expect(inGoal).toContain("inside the typical diabetes management goal"); + + const outOfGoal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }], + hasDiabetes: true, + })!; + expect(outOfGoal).toContain("outside the typical diabetes management goal"); + }); + + it("treats a band-less metric (HRV) as baseline-led, never out-of-band", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "HEART_RATE_VARIABILITY", value: 45 }], + hasDiabetes: false, + })!; + expect(block).toContain("no fixed population band"); + expect(block).not.toContain("sits outside"); + }); + + it("is deterministic — identical inputs produce byte-identical output", () => { + const input = { + metrics: [ + { metric: "BLOOD_PRESSURE" as const, value: 134 }, + { metric: "BLOOD_GLUCOSE" as const, value: 105 }, + { metric: "SLEEP_DURATION" as const, value: 7.5 }, + ], + hasDiabetes: false, + }; + expect(buildReferenceGroundingBlock(input)).toEqual( + buildReferenceGroundingBlock(input), + ); + }); + + it("caps the number of grounded lines and de-dupes metrics", () => { + const block = buildReferenceGroundingBlock({ + metrics: [ + { metric: "BMI", value: 22 }, + { metric: "BMI", value: 23 }, + ], + hasDiabetes: false, + })!; + // De-duped: only one BMI line. + const bmiLines = block.split("\n").filter((l) => l.startsWith("- BMI:")); + expect(bmiLines).toHaveLength(1); + }); + + it("contains NO commercial brand token in any branch", () => { + // Exercise both the non-diabetic and diabetic glucose branches plus a + // broad metric spread so every code path's text is scanned. + const blocks = [ + buildReferenceGroundingBlock({ + metrics: [ + { metric: "BLOOD_PRESSURE", value: 130 }, + { metric: "BLOOD_GLUCOSE", value: 95 }, + { metric: "RESTING_HEART_RATE", value: 58 }, + { metric: "OXYGEN_SATURATION", value: 97 }, + { metric: "RESPIRATORY_RATE", value: 16 }, + { metric: "BODY_TEMPERATURE", value: 36.8 }, + { metric: "BMI", value: 27 }, + { metric: "STEPS", value: 9000 }, + { metric: "VISCERAL_FAT", value: 8 }, + { metric: "SLEEP_DURATION", value: 8 }, + { metric: "HEART_RATE_VARIABILITY", value: 40 }, + ], + hasDiabetes: false, + }), + buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 140 }], + hasDiabetes: true, + }), + ]; + for (const block of blocks) { + expect(block).not.toBeNull(); + for (const brand of BRAND_TOKENS) { + expect(block!.toLowerCase()).not.toContain(brand.toLowerCase()); + } + } + }); + + it("only cites real entries from the medical-citations registry", () => { + const block = buildReferenceGroundingBlock({ + metrics: [ + { metric: "BLOOD_PRESSURE", value: 120 }, + { metric: "STEPS", value: 9000 }, + { metric: "SLEEP_DURATION", value: 8 }, + ], + hasDiabetes: false, + })!; + // Each cited source label ("NAME (YEAR)") corresponds to a real + // registry entry — no fabricated guideline names. + const citationNames = Object.values(CITATIONS).map((c) => c.name); + // The BP / steps / sleep headline sources must all appear by name. + expect(citationNames.some((n) => block.includes(n))).toBe(true); + expect(block).toContain("ESH 2023"); + expect(block).toContain("AASM"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 4000f8ca9..c94c65bb6 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -897,3 +897,96 @@ describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L- expect(hit).toBe(true); }); }); + +// v1.18.6 (W7) — the citation-aware reference-range grounding rides through +// buildCoachSnapshot for the metrics present, gated on the W6 diabetes opt-in. +describe("buildCoachSnapshot — reference grounding (W7)", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetCoachSnapshotCacheForTests(); + prismaMock.measurement.findMany.mockResolvedValue([]); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); + prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); + featuresMock.mockResolvedValue({}); + }); + + it("attaches an ESH-2023-cited grounding block when blood pressure is present", async () => { + featuresMock.mockResolvedValue({ + bloodPressure: { avgSys30: 138, avgDia30: 85, coverage: { count: 4 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 138, "BLOOD_PRESSURE_SYS"), + daysAgo(2, 85, "BLOOD_PRESSURE_DIA"), + ]); + + const out = await buildCoachSnapshot("user-1", { sources: ["bp"] }); + expect(out.referenceGrounding).not.toBeNull(); + expect(out.referenceGrounding).toContain("REFERENCE GROUNDING"); + expect(out.referenceGrounding).toContain("ESH 2023"); + // 138 sits in the high-normal band (130–139), two tiers past optimal → + // the four-state contract reads that as "outside". + expect(out.referenceGrounding).toContain( + "sits outside the general reference band", + ); + expect(out.referenceGrounding).toContain("not a diagnosis"); + }); + + it("returns null grounding when no present metric is covered", async () => { + const out = await buildCoachSnapshot("user-1"); + // Empty snapshot — no reference-covered scalar collected. + expect(out.referenceGrounding).toBeNull(); + }); + + it("uses the general non-diabetic glucose band when hasDiabetes is unset", async () => { + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + { ...daysAgo(2, 95, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" }, + ]); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + hasDiabetes: false, + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] }); + expect(out.referenceGrounding).toContain("general non-diabetic normal"); + expect(out.referenceGrounding).not.toContain("management goal"); + }); + + it("uses the tighter ADA goal band when hasDiabetes is opted in", async () => { + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + { ...daysAgo(2, 150, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" }, + ]); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + hasDiabetes: true, + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] }); + expect(out.referenceGrounding).toContain("80–130 mg/dL"); + expect(out.referenceGrounding).toContain("management goal"); + // 150 fasting is outside the diabetic goal band. + expect(out.referenceGrounding).toContain( + "outside the typical diabetes management goal", + ); + }); + + it("never emits a commercial brand token in the grounding block", async () => { + featuresMock.mockResolvedValue({ + bloodPressure: { avgSys30: 120, coverage: { count: 4 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 120, "BLOOD_PRESSURE_SYS"), + daysAgo(2, 78, "BLOOD_PRESSURE_DIA"), + ]); + const out = await buildCoachSnapshot("user-1", { sources: ["bp"] }); + for (const brand of ["Mounjaro", "Withings", "WHOOP", "Oura", "Dexcom"]) { + expect(out.referenceGrounding!.toLowerCase()).not.toContain( + brand.toLowerCase(), + ); + } + }); +}); diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts new file mode 100644 index 000000000..7c1ff6a94 --- /dev/null +++ b/src/lib/ai/coach/reference-grounding.ts @@ -0,0 +1,252 @@ +/** + * v1.18.6 — Coach reference-range grounding (W7). + * + * Turns the population reference bands W5 landed in + * `src/lib/reference-ranges.ts` into a compact, citation-aware text block + * the Coach prompt can read so a reply is GROUNDED in published guidance + * rather than the model's parametric memory of "what's normal". For each + * metric the snapshot actually carries, the block states: + * - the metric's headline reference band(s), cited to the publishing + * body + year (via `referenceLabel` → `medical-citations`), and + * - where the user's current value sits against those bands, using the + * SAME deterministic `classifyReference()` four-state contract the UI + * tiles use — never a recomputed placement. + * + * GUARDRAILS (firm — a hallucination-QA pass audits this exact text): + * - GENERAL guidance only. The block opens AND closes with a caveat that + * these are population anchors, not personal medical advice or a + * diagnosis. The Coach safety discipline (refusal rules, dose-deferral) + * is unchanged — this is read-only context, never an instruction. + * - NO commercial brand names. Every string is sourced from + * `reference-ranges.ts` (brand-free by construction) + the metric's + * natural-language label here. The unit test asserts the rendered + * block contains no brand token. + * - Blood pressure is labelled to ESH 2023 (the SHIPPED European + * default); the ACC/AHA + ESC framings ride only as `conflicts` + * CONTEXT, exactly as the band data authored them — never as the + * placement. + * - GLUCOSE respects the W6 `hasDiabetes` opt-in. When the user has NOT + * opted in, glucose is grounded against the GENERAL non-diabetic + * fasting bands. When they HAVE, the block states the tighter ADA + * glycemic GOAL band (fasting 80–130 mg/dL) as a clinician-set target, + * never a screening threshold and never a diagnosis. + * + * Deterministic + pure: same inputs → byte-identical block, no DB, no LLM, + * no per-call randomness. The block is surfaced verbatim in the Coach + * user-prompt after the SNAPSHOT so it is fully inspectable. + * + * Token budget: the block is bounded by the number of metrics present + * (one short line each) and a hard `MAX_GROUNDING_METRICS` cap, so even a + * 15-metric snapshot stays a few hundred tokens. + */ +import { + classifyReference, + getReferenceRange, + referenceLabel, + type ReferenceMetric, + type ReferencePlacement, +} from "@/lib/reference-ranges"; + +/** + * One metric the snapshot carries, paired with the user's current + * representative value in the metric's reference unit. The caller + * (`snapshot.ts`) resolves the value from the same aggregates it already + * built, so the grounding reads the SAME number the rest of the snapshot + * does. + */ +export interface GroundingMetricInput { + readonly metric: ReferenceMetric; + /** + * Current representative value in the metric's reference unit (e.g. the + * 30-day mean systolic in mmHg, the fasting-glucose mean in mg/dL). Null + * when the snapshot has the metric block but no usable scalar — the line + * still cites the band, with an `insufficient` placement. + */ + readonly value: number | null; +} + +export interface ReferenceGroundingInput { + readonly metrics: readonly GroundingMetricInput[]; + /** + * The user's explicit, declared diabetes opt-in (`User.hasDiabetes`, + * resolved by the W6 path). Selects the tighter ADA glucose GOAL band + * for the glucose line ONLY. Never inferred from a reading. + */ + readonly hasDiabetes: boolean; +} + +/** + * Hard cap on grounding lines so a maxed-out cluster set cannot balloon + * the block. Metrics beyond the cap are dropped silently (the snapshot + * still carries their data; only the extra grounding line is shed). The + * cap sits above the universal-metric count so a normal account is never + * trimmed. + */ +const MAX_GROUNDING_METRICS = 14; + +/** + * Natural-language label for each grounded metric. Deliberately the + * user-facing phrasing the Coach prompt already mandates (GROUND RULE 8 — + * never the DB enum). No brand names by construction. + */ +const METRIC_LABEL: Record = { + BLOOD_PRESSURE: "blood pressure (systolic)", + PULSE_PRESSURE: "pulse pressure", + MEAN_ARTERIAL_PRESSURE: "mean arterial pressure", + PULSE_WAVE_VELOCITY: "pulse-wave velocity", + RESTING_HEART_RATE: "resting heart rate", + HEART_RATE_VARIABILITY: "heart-rate variability", + OXYGEN_SATURATION: "oxygen saturation", + RESPIRATORY_RATE: "respiratory rate", + BODY_TEMPERATURE: "body temperature", + BLOOD_GLUCOSE: "fasting glucose", + HBA1C: "HbA1c", + BMI: "BMI", + STEPS: "daily steps", + VISCERAL_FAT: "visceral-fat rating", + SLEEP_DURATION: "sleep duration", +}; + +/** + * The ADA diabetic glycemic GOAL fasting band (mg/dL), kept in lock-step + * with `src/lib/targets/glucose-targets.ts` `DIABETIC_GOAL_BANDS.FASTING`. + * Only used to phrase the glucose grounding line when `hasDiabetes` is set; + * the placement still defers to the user's clinician, never to a band edge. + */ +const ADA_DIABETIC_FASTING_GOAL = { low: 80, high: 130 } as const; + +/** Map a four-state placement to a short, general-guidance phrase. */ +function placementPhrase(placement: ReferencePlacement): string { + switch (placement) { + case "within": + return "sits inside the general reference band"; + case "slightly-outside": + return "sits just outside the general reference band"; + case "outside": + return "sits outside the general reference band"; + case "insufficient": + // No scalar, or a metric with no fixed population band (e.g. HRV) — + // the band is cited for education, the placement is left to the + // user's own baseline. + return "has no fixed population band — your own trend leads"; + } +} + +/** + * Render the half-open band as a compact "low–high unit" descriptor for the + * metric's headline (first/normal) band. Open-ended bounds render with a + * leading/trailing ≤ / ≥. + */ +function headlineBandText(metric: ReferenceMetric): string | null { + const range = getReferenceRange(metric); + if (!range || range.bands.length === 0) return null; + const band = range.bands[0]; + const unit = range.unit; + if (band.low != null && band.high != null) { + return `${band.low}–${band.high} ${unit}`; + } + if (band.high != null) return `≤${band.high} ${unit}`; + if (band.low != null) return `≥${band.low} ${unit}`; + return null; +} + +/** + * Build the glucose grounding line. Branches on the W6 diabetes opt-in: + * general non-diabetic fasting band by default, the tighter ADA glycemic + * GOAL band when the user declared diabetes. The diabetic branch frames the + * band as a clinician-set GOAL, never a screening threshold. + */ +function glucoseLine(value: number | null, hasDiabetes: boolean): string { + const label = METRIC_LABEL.BLOOD_GLUCOSE; + if (hasDiabetes) { + const { low, high } = ADA_DIABETIC_FASTING_GOAL; + // Placement against the diabetic GOAL band, framed as a target the + // user's clinician individualises — never a diagnostic call. + let where: string; + if (value == null || !Number.isFinite(value)) { + where = "no usable value this window"; + } else if (value >= low && value <= high) { + where = "is inside the typical diabetes management goal"; + } else { + where = "is outside the typical diabetes management goal"; + } + return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel( + "BLOOD_GLUCOSE", + )} — clinician-set goal, individualised, not a screening line). Yours ${where}.`; + } + const band = headlineBandText("BLOOD_GLUCOSE"); + const placement = classifyReference("BLOOD_GLUCOSE", value); + return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel( + "BLOOD_GLUCOSE", + )}). Yours ${placementPhrase(placement)}.`; +} + +/** Build one standard (non-glucose) grounding line for a metric. */ +function standardLine(metric: ReferenceMetric, value: number | null): string { + const label = METRIC_LABEL[metric]; + const band = headlineBandText(metric); + const placement = classifyReference(metric, value); + const cite = referenceLabel(metric); + // Blood pressure cites ESH 2023 by construction (the reference-range + // headline band is sourced to ESH_2023_HYPERTENSION); the conflicts + // (ACC/AHA, ESC) stay out of the per-line copy and live in the block + // preamble's "guidelines disagree" note so the Coach never mislabels the + // shipped European line. + if (band) { + return `- ${label}: general reference ${band} (${cite}). Yours ${placementPhrase( + placement, + )}.`; + } + // No fixed band (HRV) — cite the source, defer wholly to the baseline. + return `- ${label}: ${placementPhrase(placement)} (${cite}).`; +} + +/** + * Build the Coach reference-grounding block, or null when no metric in the + * input is covered by the reference backbone (so the prompt carries no + * empty section). The returned string is plain text with a stable shape: + * a header, a general-guidance preamble, one line per metric, and a + * closing caveat. The route appends it verbatim after the SNAPSHOT. + */ +export function buildReferenceGroundingBlock( + input: ReferenceGroundingInput, +): string | null { + // De-dupe + keep input order; only metrics the backbone covers. + const seen = new Set(); + const ordered: GroundingMetricInput[] = []; + for (const m of input.metrics) { + if (seen.has(m.metric)) continue; + if (!getReferenceRange(m.metric)) continue; + seen.add(m.metric); + ordered.push(m); + if (ordered.length >= MAX_GROUNDING_METRICS) break; + } + if (ordered.length === 0) return null; + + const lines = ordered.map((m) => + m.metric === "BLOOD_GLUCOSE" + ? glucoseLine(m.value, input.hasDiabetes) + : standardLine(m.metric, m.value), + ); + + // The preamble + closing caveat carry the firm general-guidance framing. + // Blood-pressure guideline disagreement is surfaced once here (context), + // never as a per-line placement, so the Coach reads ESH 2023 as the + // shipped line. + const header = "REFERENCE GROUNDING (general guidance — not a diagnosis)"; + const preamble = [ + "These are published POPULATION reference bands for context only — they", + "are not personal medical advice, not a diagnosis, and not a target set", + "for this user. The user's own baseline always leads the read. Cite a", + "band only to orient a reply (\"the general reference range is …\"); never", + "tell the user they \"have\" or \"don't have\" a condition from a band.", + "Blood pressure follows the European ESH 2023 line; US (ACC/AHA) and", + "ESC framings differ and are context, not the call. This block does not", + "change any safety rule above — keep deferring dose / diagnosis / drug-", + "level questions to the user's clinician exactly as instructed.", + ].join("\n"); + const closing = + "Reminder: ranges are general guidance, not personal medical advice."; + + return `${header}\n${preamble}\n\n${lines.join("\n")}\n\n${closing}`; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index f8e82811a..339482f58 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -49,6 +49,11 @@ import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; import { buildIllnessSnapshotBlock } from "./illness-snapshot"; +import { + buildReferenceGroundingBlock, + type GroundingMetricInput, +} from "./reference-grounding"; +import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { @@ -81,6 +86,15 @@ export interface CoachSnapshotResult { * the model could see. */ provenance: CoachProvenance; + /** + * v1.18.6 (W7) — citation-aware reference-range grounding for the + * metrics present in this snapshot, or null when none is covered by the + * reference backbone. The Coach route appends it verbatim after the + * SNAPSHOT so the model reads published population bands + the user's + * placement (general guidance, never a diagnosis). Built deterministically + * from `src/lib/reference-ranges.ts`; carries no commercial brand name. + */ + referenceGrounding: string | null; } /** @@ -565,6 +579,11 @@ async function buildCoachSnapshotImpl( // display unit so the Coach reads the same number every other surface // shows. Read it on this existing prefs hop (no extra round-trip). glucoseUnit: true, + // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects + // the tighter ADA glycemic GOAL band for the glucose reference-grounding + // line only; never inferred from a reading, never a diagnosis. Read on + // this existing prefs hop (no extra round-trip). + hasDiabetes: true, }, }); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); @@ -652,6 +671,37 @@ async function buildCoachSnapshotImpl( const metrics = new Set(); const counts: NonNullable = {}; + // v1.18.6 (W7) — representative scalar per reference-covered metric, in the + // metric's reference unit, collected as the blocks below build. Feeds the + // citation-aware grounding block (population band + the user's placement). + // Each entry reads the SAME number the snapshot already surfaces — no + // independent recompute — so the grounding can never cite a value the + // snapshot doesn't carry. A metric with a block but no clean scalar is left + // unset (the grounding line still cites its band with an insufficient + // placement only if it is added with a null value; we omit such metrics). + const groundingValues = new Map(); + // Additive `MeasurementType` → reference metric, for the value-block loop. + const TYPE_TO_REFERENCE_METRIC: Record = { + RESTING_HEART_RATE: "RESTING_HEART_RATE", + OXYGEN_SATURATION: "OXYGEN_SATURATION", + RESPIRATORY_RATE: "RESPIRATORY_RATE", + PULSE_WAVE_VELOCITY: "PULSE_WAVE_VELOCITY", + BODY_TEMPERATURE: "BODY_TEMPERATURE", + BODY_MASS_INDEX: "BMI", + VISCERAL_FAT: "VISCERAL_FAT", + ACTIVITY_STEPS: "STEPS", + }; + /** Mean of the most recent N daily-value rows (already user-tz day means). */ + const recentRowsMean = ( + rows: DailyValueRow[], + take = DAILY_TIMELINE_DAYS, + ): number | null => { + if (rows.length === 0) return null; + const slice = rows.slice(-take); + const sum = slice.reduce((s, r) => s + r.value, 0); + return sum / slice.length; + }; + // v1.7.0 — block registry. Maps each emitted snapshot top-level key // to the cluster it belongs to so the soft-cap degradation pass // (below) can walk blocks in reverse cluster-priority order and shed @@ -1119,6 +1169,10 @@ async function buildCoachSnapshotImpl( windows.add("last90days"); counts.bp = features.bloodPressure.coverage?.count ?? undefined; registerBlock("bloodPressure", "bp"); + // W7 grounding: systolic 30-day mean against the ESH 2023 office bands. + const sys30 = + features.bloodPressure.avgSys30 ?? features.bloodPressure.allTimeAvgSys; + if (sys30 != null) groundingValues.set("BLOOD_PRESSURE", sys30); } if (wantsWeight && features.weight) { const rows = byType("WEIGHT"); @@ -1137,6 +1191,13 @@ async function buildCoachSnapshotImpl( windows.add("last30days"); counts.weight = features.weight.coverage?.count ?? undefined; registerBlock("weight", "weight"); + // W7 grounding: weight has no population band, but the derived BMI does. + // Use the features-computed BMI (same value the weight tile shows) so the + // grounding reads the WHO band. A dedicated BODY_MASS_INDEX block (below) + // wins if the user also syncs a measured BMI series. + if (features.weight.bmi != null) { + groundingValues.set("BMI", features.weight.bmi); + } } if (wantsPulse && features.pulse) { const rows = byType("PULSE"); @@ -1412,6 +1473,14 @@ async function buildCoachSnapshotImpl( metrics.add(block.metric); counts[block.metric] = rows.length; registerBlock(block.snapshotKey, block.source); + // W7 grounding: when this additive series maps to a reference metric, + // record the recent daily mean (same per-day means the timeline shows). + const refMetric = TYPE_TO_REFERENCE_METRIC[block.type]; + if (refMetric) { + const recentRows = buildDailyValueRows(rows, recentCutoff, userTz); + const mean = recentRowsMean(recentRows); + if (mean != null) groundingValues.set(refMetric, mean); + } } // ── v1.7.0 sleep block (with optional per-stage enrichment) ─────── @@ -1480,6 +1549,19 @@ async function buildCoachSnapshotImpl( metrics.add("sleep"); counts.sleep = sleepRows.length; registerBlock("sleep", "sleep"); + // W7 grounding: recent nightly asleep duration in HOURS against the + // AASM 7–9 h band (the reference unit is hours; the snapshot stores + // minutes). Mean over the recent nights the block already reconstructed. + if (recentNights.length > 0) { + const meanMin = + recentNights.reduce( + (s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0), + 0, + ) / recentNights.length; + if (meanMin > 0) { + groundingValues.set("SLEEP_DURATION", meanMin / 60); + } + } } } @@ -1653,6 +1735,22 @@ async function buildCoachSnapshotImpl( metrics.add("glucose"); counts.glucose = glucoseRows.length; registerBlock("glucose", "glucose"); + // W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's + // unit), independent of the user's mmol/L display preference. The + // grounding line's band selection respects the W6 `hasDiabetes` opt-in; + // here we only feed the representative fasting value. Fall back to the + // overall mean when no row is tagged FASTING so the band is still cited. + const fastingRows = glucoseRows.filter( + (r) => String(r.glucoseContext).toUpperCase() === "FASTING", + ); + const glucoseScalarRows = + fastingRows.length > 0 ? fastingRows : glucoseRows; + const glucoseMeanMgdl = + glucoseScalarRows.reduce((s, r) => s + r.value, 0) / + glucoseScalarRows.length; + if (Number.isFinite(glucoseMeanMgdl)) { + groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl); + } } } @@ -1894,6 +1992,29 @@ async function buildCoachSnapshotImpl( // `coach.snapshot.truncated` annotation when it sheds anything. degradeToBudget(compactSnapshot, blockClusters); + // v1.18.6 (W7) — build the citation-aware reference-grounding block from the + // representative scalars collected above. Deterministic + pure; the route + // appends it verbatim after the SNAPSHOT. Null when no present metric is + // covered by the reference backbone. Insertion order follows the block-build + // order (BP first), giving a stable, inspectable block for the + // hallucination-QA pass. + const groundingMetrics: GroundingMetricInput[] = Array.from( + groundingValues.entries(), + ).map(([metric, value]) => ({ metric, value })); + const referenceGrounding = buildReferenceGroundingBlock({ + metrics: groundingMetrics, + hasDiabetes: prefsRow?.hasDiabetes ?? false, + }); + if (referenceGrounding) { + annotate({ + action: { name: "coach.grounding.attached" }, + meta: { + metrics: groundingMetrics.map((m) => m.metric).sort(), + hasDiabetes: prefsRow?.hasDiabetes ?? false, + }, + }); + } + return { snapshotJson: JSON.stringify(compactSnapshot, null, 2), provenance: { @@ -1901,6 +2022,7 @@ async function buildCoachSnapshotImpl( metrics: Array.from(metrics), counts: Object.keys(counts).length > 0 ? counts : undefined, }, + referenceGrounding, }; } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 608aaf262..0c94d276b 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -189,6 +189,22 @@ GROUND RULES day, decline and say the forecast is a descriptive estimate, not a contraceptive method. When NO "cycle" block is present, do not infer or guess a phase. +13. The user prompt MAY carry a "REFERENCE GROUNDING" block after the + SNAPSHOT. It lists published POPULATION reference bands for the + metrics present plus where the user's value sits, each cited to its + source (e.g. ESH 2023 for blood pressure). Use it ONLY as general + orientation — "the general reference range is …", "that sits a little + above the usual band". It is NOT personal medical advice, NOT a + diagnosis, and NOT a target for this user; the user's own baseline + still leads. Never tell the user they "have" or "don't have" a + condition from a band, never convert a band into a risk score, and + never let the grounding soften the safety rules above — keep deferring + dose / diagnosis / drug-level questions to their clinician. Blood + pressure reads on the European ESH 2023 line; the US (ACC/AHA) and + ESC framings differ and are context only. If the user has declared + diabetes, the glucose line states a clinician-set management GOAL, not + a screening threshold. When no REFERENCE GROUNDING block is present, + do not invent a band — pivot to the user's own trend. DAY-LEVEL READINGS — USE THE TIMELINE @@ -580,6 +596,25 @@ GRUNDREGELN dass die Prognose eine beschreibende Schätzung ist, keine Verhütungsmethode. Wenn KEIN "cycle"-Block vorhanden ist, leite oder rate keine Phase ab. +13. Der User-Prompt kann nach dem SNAPSHOT einen "REFERENCE + GROUNDING"-Block mitbringen. Er nennt publizierte POPULATIONS- + Referenzbereiche für die vorhandenen Metriken plus die Einordnung des + Nutzerwerts, jeweils mit Quelle zitiert (z. B. ESH 2023 für den + Blutdruck). Nutze ihn AUSSCHLIESSLICH zur groben Orientierung — "der + allgemeine Referenzbereich liegt bei …", "das liegt etwas über dem + üblichen Bereich". Er ist KEINE persönliche medizinische Beratung, + KEINE Diagnose und KEIN Zielwert für diesen Nutzer; die eigene + Ausgangslage des Nutzers führt weiterhin. Sag dem Nutzer nie, er + "habe" oder "habe nicht" eine Erkrankung aus einem Bereich, mach aus + einem Bereich nie einen Risiko-Score, und lass das Grounding nie die + Sicherheitsregeln oben aufweichen — verweise Dosis-/Diagnose-/ + Wirkstoffspiegel-Fragen weiter an die behandelnde Ärztin. Der + Blutdruck liest sich auf der europäischen ESH-2023-Linie; die US- + (ACC/AHA) und ESC-Einordnungen weichen ab und sind nur Kontext. Hat + der Nutzer Diabetes angegeben, nennt die Glukose-Zeile ein + klinisch gesetztes Management-ZIEL, keinen Screening-Schwellwert. + Ist kein REFERENCE-GROUNDING-Block vorhanden, erfinde keinen Bereich + — wechsle zum eigenen Trend des Nutzers. TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE