From 976ecf8a75dbc8b830b5ccf52b87489d45e38c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:36:59 +0200 Subject: [PATCH] Surface the computed illness retrospective to the Coach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The illness-recovery tool and the illness context block carried only labels, lifecycle, and dates plus the recovery composite — never the recovery-gap, the metric that drove it, the nadir, the pre-onset deviations, or the red flags the illness card renders. So a recovery question got the composite, not the gap the user sees, and a sustained-fever or low-SpO2 escalation was invisible in-conversation. Run the existing episode-correlation read-layer (the same engine the card and the red-flag notifier use) for the most relevant episode — active first, else the most-recently-resolved — and attach the computed scores to get_illness_recovery, coverage-gated so a thin signal yields nothing rather than a fabricated number. Document the illnessScores shape in the Coach prompt so it restates those numbers verbatim and escalates a red flag rather than reassuring. --- .../ai/coach/__tests__/illness-scores.test.ts | 180 ++++++++++++++++++ .../ai/coach/__tests__/tools-executor.test.ts | 81 ++++++++ src/lib/ai/coach/illness-snapshot.ts | 173 +++++++++++++++++ src/lib/ai/coach/system-prompt.ts | 19 ++ src/lib/ai/coach/tools/executor.ts | 11 +- 5 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 src/lib/ai/coach/__tests__/illness-scores.test.ts diff --git a/src/lib/ai/coach/__tests__/illness-scores.test.ts b/src/lib/ai/coach/__tests__/illness-scores.test.ts new file mode 100644 index 000000000..8af760666 --- /dev/null +++ b/src/lib/ai/coach/__tests__/illness-scores.test.ts @@ -0,0 +1,180 @@ +/** + * v1.21.0 (NEW-B B-2) — the Coach illness-scores block. + * + * The Coach illness CONTEXT block carries only labels + lifecycle + dates; this + * block surfaces the computed retrospective the illness card shows (recovery- + * gap, gap-driver, nadir, pre-onset, red flags) for the most relevant episode, + * read-only and coverage-gated. These guards assert the gating, the + * active-wins-over-resolved selection, the finding compaction, and that a + * withheld (insufficient) episode yields null — never a fabricated number. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const isIllnessEnabled = vi.fn(); +const computeEpisodeCorrelation = vi.fn(); +const findUnique = vi.fn(); +const findFirst = vi.fn(); + +vi.mock("@/lib/illness/gate", () => ({ + isIllnessEnabled: (userId: string) => isIllnessEnabled(userId), +})); +vi.mock("@/lib/illness/correlation-read", () => ({ + computeEpisodeCorrelation: (...args: unknown[]) => + computeEpisodeCorrelation(...args), +})); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: (a: unknown) => findUnique(a) }, + illnessEpisode: { findFirst: (a: unknown) => findFirst(a) }, + }, +})); + +import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot"; + +const ACTIVE = { + id: "ep-active", + label: "flu", + type: "INFECTION", + onsetAt: new Date("2026-06-10T00:00:00Z"), + resolvedAt: null, + lifecycle: "ACTIVE", +}; +const RESOLVED = { + id: "ep-resolved", + label: "cold", + type: "INFECTION", + onsetAt: new Date("2026-05-01T00:00:00Z"), + resolvedAt: new Date("2026-05-08T00:00:00Z"), + lifecycle: "RESOLVED", +}; + +function okDerived() { + return { + status: "ok" as const, + value: { + episodeId: "ep-active", + recoveryGapDays: 4, + gapDriverType: "RESTING_HEART_RATE", + nadir: [ + { + type: "HEART_RATE_VARIABILITY", + day: "2026-06-12", + value: 30, + baselineCenter: 55, + deviationSd: -2.43, + direction: "below" as const, + adverse: true, + }, + { + type: "WEIGHT", + day: "2026-06-12", + value: 80, + baselineCenter: 81, + deviationSd: -0.5, + direction: "below" as const, + adverse: false, + }, + ], + preOnset: [], + redFlags: [ + { + type: "BODY_TEMPERATURE", + reason: "sustained_fever" as const, + worstValue: 38.9, + days: 3, + }, + ], + }, + }; +} + +describe("buildIllnessScores", () => { + beforeEach(() => { + isIllnessEnabled.mockReset(); + computeEpisodeCorrelation.mockReset(); + findUnique.mockReset(); + findFirst.mockReset(); + isIllnessEnabled.mockResolvedValue(true); + findUnique.mockResolvedValue({ timezone: "Europe/Berlin" }); + }); + + it("returns null when the illness module is off (no read)", async () => { + isIllnessEnabled.mockResolvedValue(false); + const out = await buildIllnessScores("u1"); + expect(out).toBeNull(); + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("returns null when there is no episode to score", async () => { + findFirst.mockResolvedValue(null); + const out = await buildIllnessScores("u1"); + expect(out).toBeNull(); + expect(computeEpisodeCorrelation).not.toHaveBeenCalled(); + }); + + it("prefers the active episode and surfaces the computed scores", async () => { + // First findFirst call = active, second = resolved. Active wins. + findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(RESOLVED); + computeEpisodeCorrelation.mockResolvedValue(okDerived()); + const out = await buildIllnessScores("u1"); + expect(out).not.toBeNull(); + // The active episode drove the read. + expect(computeEpisodeCorrelation).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ id: "ep-active" }), + "Europe/Berlin", + expect.any(Date), + ); + expect(out).toMatchObject({ + episodeLabel: "flu", + state: "active", + recoveryGapDays: 4, + gapDriverType: "RESTING_HEART_RATE", + }); + // Red flags carried verbatim. + expect(out?.redFlags).toEqual([ + { + type: "BODY_TEMPERATURE", + reason: "sustained_fever", + worstValue: 38.9, + days: 3, + }, + ]); + }); + + it("compacts nadir findings adverse-first and rounds the deviation", async () => { + findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(null); + computeEpisodeCorrelation.mockResolvedValue(okDerived()); + const out = await buildIllnessScores("u1"); + // Adverse HRV finding leads the neutral WEIGHT one; deviation rounded to .1. + expect(out?.nadir[0]).toEqual({ + type: "HEART_RATE_VARIABILITY", + day: "2026-06-12", + deviationSd: -2.4, + direction: "below", + }); + }); + + it("falls back to the most-recently-resolved episode when none active", async () => { + findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(RESOLVED); + computeEpisodeCorrelation.mockResolvedValue({ + ...okDerived(), + value: { ...okDerived().value, episodeId: "ep-resolved" }, + }); + const out = await buildIllnessScores("u1"); + expect(computeEpisodeCorrelation).toHaveBeenCalledWith( + "u1", + expect.objectContaining({ id: "ep-resolved" }), + "Europe/Berlin", + expect.any(Date), + ); + expect(out?.state).toBe("resolved"); + }); + + it("returns null when the engine withholds (insufficient coverage)", async () => { + findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(null); + computeEpisodeCorrelation.mockResolvedValue({ status: "insufficient" }); + const out = await buildIllnessScores("u1"); + expect(out).toBeNull(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index c3c847a80..79e703b5a 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -21,6 +21,14 @@ vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), })); +// v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks +// with the computed retrospective scores. Mock the read-only engine wrapper so +// the executor test stays a pure dispatch test. +const buildIllnessScores = vi.fn(); +vi.mock("@/lib/ai/coach/illness-snapshot", () => ({ + buildIllnessScores: (userId: string) => buildIllnessScores(userId), +})); + import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; function snapshot( @@ -39,6 +47,8 @@ describe("executeCoachTool", () => { beforeEach(() => { buildCoachSnapshot.mockReset(); readCoachCorrelations.mockReset(); + buildIllnessScores.mockReset(); + buildIllnessScores.mockResolvedValue(null); }); it("returns the matching section for get_metric_series when present", async () => { @@ -298,6 +308,77 @@ describe("executeCoachTool", () => { expect(result.present).toBe(false); expect(result.reason).toBe("invalid_arguments"); }); + + // v1.21.0 (NEW-B B-2) — get_illness_recovery now carries the computed + // retrospective scores the illness card shows (recovery-gap, gap-driver, + // nadir, pre-onset, red flags), not just labels + composites. + it("get_illness_recovery surfaces the computed illness scores", async () => { + buildCoachSnapshot.mockResolvedValue( + snapshot({ illness: { restMode: true, active: [], recentResolved: [] } }), + ); + buildIllnessScores.mockResolvedValue({ + episodeLabel: "flu", + episodeType: "INFECTION", + state: "resolved", + recoveryGapDays: 4, + gapDriverType: "RESTING_HEART_RATE", + nadir: [ + { + type: "HEART_RATE_VARIABILITY", + day: "2026-06-10", + deviationSd: -2.4, + direction: "below", + }, + ], + preOnset: [], + redFlags: [ + { + type: "BODY_TEMPERATURE", + reason: "sustained_fever", + worstValue: 38.9, + days: 3, + }, + ], + }); + const result = await executeCoachTool({ + userId: "u1", + name: "get_illness_recovery", + rawArguments: "{}", + }); + expect(result.present).toBe(true); + const data = result.data as { illnessScores?: Record }; + expect(data.illnessScores).toMatchObject({ + recoveryGapDays: 4, + gapDriverType: "RESTING_HEART_RATE", + }); + expect(data.illnessScores?.redFlags).toHaveLength(1); + expect(data.illnessScores?.nadir).toHaveLength(1); + }); + + it("get_illness_recovery is present on scores alone (no derived blocks)", async () => { + // No illness/derived/strain/trajectory section, but the engine returned + // scores → the tool must still report present and carry them. + buildCoachSnapshot.mockResolvedValue(snapshot({})); + buildIllnessScores.mockResolvedValue({ + episodeLabel: "cold", + episodeType: "INFECTION", + state: "active", + recoveryGapDays: null, + gapDriverType: null, + nadir: [], + preOnset: [], + redFlags: [], + }); + const result = await executeCoachTool({ + userId: "u1", + name: "get_illness_recovery", + rawArguments: "{}", + }); + expect(result.present).toBe(true); + expect( + (result.data as { illnessScores?: unknown }).illnessScores, + ).toBeTruthy(); + }); }); // v1.21.0 (D5-1) — ONE snapshot per turn. When the route threads the shared diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index 747eb91c6..5063a8ba0 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -15,6 +15,8 @@ */ import { prisma } from "@/lib/db"; import { isIllnessEnabled } from "@/lib/illness/gate"; +import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; /** How many recently-resolved episodes to include as history context. */ const RESOLVED_HISTORY_LIMIT = 6; @@ -108,3 +110,174 @@ export async function buildIllnessSnapshotBlock( })), }; } + +/* ── v1.21.0 (NEW-B B-2) — illness SCORES for the Coach ──────────────────── + * + * The Coach illness CONTEXT block above carries only labels / lifecycle / + * dates — it never reaches the computed retrospective the illness card shows + * (recovery-gap, the metric that dominated it, the nadir, and the red flags). + * So a user asking "how long did my body take to recover last time" got the + * recovery COMPOSITE, not the recovery-GAP the card renders, and a sustained- + * fever / low-SpO2 escalation was invisible in-conversation. + * + * This block surfaces those same numbers, READ-ONLY, by running the existing + * `computeEpisodeCorrelation` read-layer (the one the card + the notification + * path use) for the single most relevant episode — the active one, else the + * most-recently-resolved. The engine is coverage-gated: a thin signal returns + * `insufficient`, which yields `null` here (no fabricated number). It is + * RETROSPECTIVE and DESCRIPTIVE — never a prediction, never a diagnosis. The + * Coach reads these to restate what the card already shows. + */ + +/** Cap on per-finding rows so the block never balloons the prompt. */ +const MAX_FINDING_ROWS = 3; + +/** One vital deviation finding, compacted for the prompt. */ +export interface CoachVitalFinding { + type: string; + day: string; + deviationSd: number; + direction: "above" | "below"; +} + +/** One illness red-flag escalation, compacted for the prompt. */ +export interface CoachIllnessRedFlag { + type: string; + reason: "sustained_low_spo2" | "sustained_fever"; + worstValue: number; + days: number; +} + +/** The computed retrospective scores for the most relevant episode. */ +export interface CoachIllnessScores { + /** Which episode these scores describe (label + lifecycle, no free-text note). */ + episodeLabel: string; + episodeType: string; + /** "active" | "resolved" — the lifecycle context for the gap. */ + state: "active" | "resolved"; + /** + * The headline recovery-gap in days (the body lagged the felt-better marker), + * or null when still active / no vital returned. Mirrors the card's headline. + */ + recoveryGapDays: number | null; + /** The metric whose physiological return dominated the gap (or null). */ + gapDriverType: string | null; + /** "What dropped" — each vital's worst (nadir) deviation, capped + adverse-first. */ + nadir: CoachVitalFinding[]; + /** "How it announced itself" — notable pre-onset deviations, capped. */ + preOnset: CoachVitalFinding[]; + /** Sustained-fever / low-SpO2 escalations during the episode (empty when none). */ + redFlags: CoachIllnessRedFlag[]; +} + +function compactFindings( + findings: ReadonlyArray<{ + type: string; + day: string; + deviationSd: number; + direction: "above" | "below"; + adverse: boolean; + }>, +): CoachVitalFinding[] { + // Adverse-direction findings lead (the illness-relevant moves), then the + // largest absolute deviation; cap the row count. + return [...findings] + .sort((a, b) => { + if (a.adverse !== b.adverse) return a.adverse ? -1 : 1; + return Math.abs(b.deviationSd) - Math.abs(a.deviationSd); + }) + .slice(0, MAX_FINDING_ROWS) + .map((f) => ({ + type: f.type, + day: f.day, + // Round to one decimal — the felt grain, never false precision. + deviationSd: Math.round(f.deviationSd * 10) / 10, + direction: f.direction, + })); +} + +/** + * Build the computed illness-scores block for the Coach, or `null` when the + * module is off, there is no episode to score, or the engine withholds + * (insufficient coverage). Reads the active episode first, else the most- + * recently-resolved. Server-authoritative + coverage-gated; the engine is the + * SAME one the illness card + the red-flag notifier run. + */ +export async function buildIllnessScores( + userId: string, + now: Date = new Date(), +): Promise { + if (!(await isIllnessEnabled(userId))) return null; + + const [user, active, resolved] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }), + prisma.illnessEpisode.findFirst({ + where: { + userId, + deletedAt: null, + resolvedAt: null, + onsetAt: { lte: now }, + }, + orderBy: { onsetAt: "desc" }, + select: { + id: true, + label: true, + type: true, + onsetAt: true, + resolvedAt: true, + lifecycle: true, + }, + }), + prisma.illnessEpisode.findFirst({ + where: { userId, deletedAt: null, resolvedAt: { not: null } }, + orderBy: { resolvedAt: "desc" }, + select: { + id: true, + label: true, + type: true, + onsetAt: true, + resolvedAt: true, + lifecycle: true, + }, + }), + ]); + + // Active episode wins (it is the one a "how am I recovering" question is + // about); fall back to the most-recently-resolved for "last time" questions. + const episode = active ?? resolved; + if (!episode) return null; + + const tz = user?.timezone ?? DEFAULT_TIMEZONE; + const derived = await computeEpisodeCorrelation( + userId, + { + id: episode.id, + onsetAt: episode.onsetAt, + resolvedAt: episode.resolvedAt, + lifecycle: episode.lifecycle, + }, + tz, + now, + ); + if (derived.status !== "ok") return null; + + const v = derived.value; + return { + episodeLabel: sanitizeLabel(episode.label), + episodeType: episode.type, + state: episode.resolvedAt === null ? "active" : "resolved", + recoveryGapDays: v.recoveryGapDays, + gapDriverType: v.gapDriverType, + nadir: compactFindings(v.nadir), + preOnset: compactFindings(v.preOnset), + redFlags: v.redFlags.map((f) => ({ + type: f.type, + reason: f.reason, + worstValue: f.worstValue, + days: f.days, + })), + }; +} diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index cfa2b60b5..9dd21b76d 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -347,6 +347,15 @@ ISO-week means. "recentResolved" is light history for "how often do I get sick" questions. When no "illness" block is present, assume nothing about the user's health status. + When a "get_illness_recovery" result (or the snapshot) carries an + "illnessScores" object, it holds the computed retrospective the illness + card shows for the most relevant episode: "recoveryGapDays" (how many + days the body lagged the felt-better marker), "gapDriverType" (the metric + whose return drove that gap), "nadir" / "preOnset" (the worst and earliest + deviations), and "redFlags" (sustained fever / low-SpO2 escalations). Read + these numbers verbatim — they are the SAME ones the card renders — and a + red flag escalates ("if that recurs, seek care"), never reassures. State + them only when the object is present; never invent a recovery gap. - The SNAPSHOT MAY carry a "labs" block: { recent[] }, where each entry is the user's MOST RECENT reading for one biomarker — { analyte, panel, value, valueText, unit, referenceLow, referenceHigh, rangeStatus, @@ -790,6 +799,16 @@ ISO-Wochenmittel zusammen. Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose; "recentResolved" ist leichte Historie für "wie oft bin ich krank". Fehlt der "illness"-Block, nimm nichts über den Gesundheitszustand an. + Trägt ein "get_illness_recovery"-Ergebnis (oder der SNAPSHOT) ein + "illnessScores"-Objekt, enthält es die berechnete Retrospektive, die die + Krankheits-Karte für die relevanteste Episode zeigt: "recoveryGapDays" (wie + viele Tage der Körper dem Besser-Gefühl-Marker hinterherhinkte), + "gapDriverType" (die Metrik, deren Rückkehr den Abstand trieb), "nadir" / + "preOnset" (die stärksten und frühesten Abweichungen) und "redFlags" + (anhaltendes Fieber / niedriges SpO2). Lies diese Zahlen wörtlich — es sind + DIESELBEN, die die Karte rendert — und ein Red Flag eskaliert ("falls das + wiederkehrt, lass es abklären"), beruhigt nie. Nenne sie nur, wenn das + Objekt vorhanden ist; erfinde nie einen Recovery-Gap. - Der SNAPSHOT KANN einen "labs"-Block tragen: { recent[] }, wobei jeder Eintrag der NEUESTE Wert des Nutzers für einen Biomarker ist — { analyte, panel, value, valueText, unit, referenceLow, referenceHigh, rangeStatus, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 1d33e9726..765404be4 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -55,6 +55,7 @@ import { METRIC_SERIES_EXCLUDED_SOURCES, } from "./source-keys"; import { readCoachCorrelations } from "./correlations-read"; +import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot"; /** A read-only structured tool result. Serialised to a `role:"tool"` turn. */ export interface CoachToolResult { @@ -427,11 +428,18 @@ async function getIllnessRecovery( const derived = pickSection(snapshot.sections, "derived"); const dayStrain = pickSection(snapshot.sections, "dayStrain"); const trajectory = pickSection(snapshot.sections, "trajectory"); + // v1.21.0 (NEW-B B-2) — the computed illness retrospective the card shows + // (recovery-gap, gap-driver, nadir, pre-onset, red flags) for the most + // relevant episode. Read-only, coverage-gated (null when the engine + // withholds), and the SAME engine the card + the red-flag notifier run, so + // the Coach restates the numbers the user sees rather than the composite. + const illnessScores = await buildIllnessScores(userId); if ( illness === undefined && derived === undefined && dayStrain === undefined && - trajectory === undefined + trajectory === undefined && + illnessScores === null ) { return { present: false, reason: "no_data" }; } @@ -439,6 +447,7 @@ async function getIllnessRecovery( present: true, data: { ...(illness !== undefined ? { illness } : {}), + ...(illnessScores !== null ? { illnessScores } : {}), ...(derived !== undefined ? { derived } : {}), ...(dayStrain !== undefined ? { dayStrain } : {}), ...(trajectory !== undefined ? { trajectory } : {}),