Surface the computed illness retrospective to the Coach

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:36:59 +02:00
1 parent abceff04c7
commit 976ecf8a75
5 files changed
+463 -1

No files matched your search

@@ -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();
});
});
@@ -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<string, unknown> };
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
+173
View File
@@ -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<CoachIllnessScores | null> {
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,
})),
};
}
+19
View File
@@ -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,
+10 -1
View File
@@ -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 } : {}),