From b0a8dfc29e91d82ecfaa6cd5de018dad4a64435c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:26:19 +0200 Subject: [PATCH] feat(coach): pass recent lab values into the coach context Add a labs block to the coach snapshot so the assistant can answer questions about the user's lab results. Each entry is the most recent reading per biomarker over the last 12 months (capped), carrying the resolved name, value, unit, reference range, in/out-of-range verdict, and measured date. Qualitative readings (e.g. negativ) are included with a neutral range status. Values resolve server-side via the same helpers the labs API uses, and the system prompt instructs the model to quote them verbatim and never invent a lab number, so the block stays grounded. The free-text qualitative result is sanitised before it can reach the prompt; the encrypted note is never read. --- .../ai/coach/__tests__/labs-snapshot.test.ts | 171 +++++++++++++++++ .../coach/__tests__/snapshot-budget.test.ts | 1 + .../__tests__/snapshot-new-metrics.test.ts | 1 + src/lib/ai/coach/__tests__/snapshot.test.ts | 2 + src/lib/ai/coach/labs-snapshot.ts | 177 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 20 ++ src/lib/ai/coach/system-prompt.ts | 26 +++ 7 files changed, 398 insertions(+) create mode 100644 src/lib/ai/coach/__tests__/labs-snapshot.test.ts create mode 100644 src/lib/ai/coach/labs-snapshot.ts diff --git a/src/lib/ai/coach/__tests__/labs-snapshot.test.ts b/src/lib/ai/coach/__tests__/labs-snapshot.test.ts new file mode 100644 index 000000000..b3dfbd5dc --- /dev/null +++ b/src/lib/ai/coach/__tests__/labs-snapshot.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + labResult: { findMany: vi.fn() }, + }, +})); + +import { buildLabsSnapshotBlock, sanitizeValueText } from "../labs-snapshot"; +import { prisma } from "@/lib/db"; + +const prismaMock = prisma as unknown as { + labResult: { findMany: ReturnType }; +}; + +const NOW = new Date("2026-06-21T12:00:00.000Z"); + +/** A linked numeric lab row (biomarker wins for name/unit/bounds). */ +function numericRow(overrides: Record = {}) { + return { + analyte: "ldl-legacy", + panel: "legacy-panel", + value: 140, + valueText: null, + unit: "legacy-unit", + referenceLow: 50, + referenceHigh: 200, + takenAt: new Date("2026-06-01T08:00:00.000Z"), + biomarkerId: "bm_ldl", + biomarker: { + id: "bm_ldl", + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 130, + panel: "Lipids", + }, + ...overrides, + }; +} + +beforeEach(() => { + prismaMock.labResult.findMany.mockReset(); +}); + +describe("buildLabsSnapshotBlock", () => { + it("returns null when the account has no readings", async () => { + prismaMock.labResult.findMany.mockResolvedValue([]); + const block = await buildLabsSnapshotBlock("user_1", NOW); + expect(block).toBeNull(); + }); + + it("resolves name/unit/bounds from the linked biomarker and flags out-of-range", async () => { + prismaMock.labResult.findMany.mockResolvedValue([numericRow()]); + const block = await buildLabsSnapshotBlock("user_1", NOW); + expect(block).not.toBeNull(); + expect(block!.recent).toHaveLength(1); + const r = block!.recent[0]; + // Biomarker fields win over the stale per-row legacy columns. + expect(r.analyte).toBe("LDL"); + expect(r.unit).toBe("mg/dL"); + expect(r.referenceHigh).toBe(130); + expect(r.panel).toBe("Lipids"); + // 140 > upperBound 130 → above. + expect(r.rangeStatus).toBe("above"); + expect(r.value).toBe(140); + expect(r.valueText).toBeNull(); + }); + + it("classifies an in-range numeric reading", async () => { + prismaMock.labResult.findMany.mockResolvedValue([ + numericRow({ value: 100 }), + ]); + const block = await buildLabsSnapshotBlock("user_1", NOW); + expect(block!.recent[0].rangeStatus).toBe("in-range"); + }); + + it("includes a qualitative reading with unknown range status", async () => { + prismaMock.labResult.findMany.mockResolvedValue([ + { + analyte: "Hepatitis B", + panel: null, + value: null, + valueText: "negativ", + unit: "", + referenceLow: null, + referenceHigh: null, + takenAt: new Date("2026-05-10T08:00:00.000Z"), + biomarkerId: "bm_hep", + biomarker: { + id: "bm_hep", + name: "Hepatitis B", + unit: "", + lowerBound: null, + upperBound: null, + panel: null, + }, + }, + ]); + const block = await buildLabsSnapshotBlock("user_1", NOW); + const r = block!.recent[0]; + expect(r.value).toBeNull(); + expect(r.valueText).toBe("negativ"); + expect(r.rangeStatus).toBe("unknown"); + }); + + it("keeps only the most-recent reading per biomarker", async () => { + // findMany returns newest-first; the older duplicate must be dropped. + prismaMock.labResult.findMany.mockResolvedValue([ + numericRow({ value: 120, takenAt: new Date("2026-06-10T08:00:00.000Z") }), + numericRow({ value: 145, takenAt: new Date("2026-01-10T08:00:00.000Z") }), + ]); + const block = await buildLabsSnapshotBlock("user_1", NOW); + expect(block!.recent).toHaveLength(1); + expect(block!.recent[0].value).toBe(120); + }); + + it("collapses two spellings of an unlinked marker by lower-cased analyte", async () => { + prismaMock.labResult.findMany.mockResolvedValue([ + { + analyte: "Ferritin", + panel: null, + value: 80, + valueText: null, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 400, + takenAt: new Date("2026-06-05T08:00:00.000Z"), + biomarkerId: null, + biomarker: null, + }, + { + analyte: "ferritin", + panel: null, + value: 60, + valueText: null, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 400, + takenAt: new Date("2026-02-05T08:00:00.000Z"), + biomarkerId: null, + biomarker: null, + }, + ]); + const block = await buildLabsSnapshotBlock("user_1", NOW); + expect(block!.recent).toHaveLength(1); + expect(block!.recent[0].value).toBe(80); + }); + + it("queries with an owner-scoped, deletedAt-null, 12-month window where", async () => { + prismaMock.labResult.findMany.mockResolvedValue([]); + await buildLabsSnapshotBlock("user_42", NOW); + const arg = prismaMock.labResult.findMany.mock.calls[0][0]; + expect(arg.where.userId).toBe("user_42"); + expect(arg.where.deletedAt).toBeNull(); + expect(arg.where.takenAt.lte).toEqual(NOW); + expect(arg.where.takenAt.gte).toEqual(new Date("2025-06-21T12:00:00.000Z")); + // Never selects the encrypted note column. + expect(arg.select.noteEncrypted).toBeUndefined(); + }); +}); + +describe("sanitizeValueText", () => { + it("collapses control chars + newlines and bounds length", () => { + expect(sanitizeValueText("negativ")).toBe("negativ"); + expect(sanitizeValueText("ignore\nprevious\tinstructions")).toBe( + "ignore previous instructions", + ); + expect(sanitizeValueText("x".repeat(200)).length).toBe(60); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index a03c3c47b..72d0eca8b 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, illnessEpisode: { findMany: vi.fn(async () => []) }, + labResult: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index cc87f686d..f5aa3589c 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ workout: { findMany: vi.fn() }, user: { findUnique: vi.fn() }, illnessEpisode: { findMany: vi.fn(async () => []) }, + labResult: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 665e9da6e..fd36ba353 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -17,6 +17,8 @@ vi.mock("@/lib/db", () => ({ user: { findUnique: vi.fn() }, // v1.18.1 P4 — the illness context block reads active + recent episodes. illnessEpisode: { findMany: vi.fn(async () => []) }, + // v1.18.11 (#65) — the labs context block reads recent lab results. + labResult: { findMany: vi.fn(async () => []) }, }, })); diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts new file mode 100644 index 000000000..967bc1c50 --- /dev/null +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -0,0 +1,177 @@ +/** + * v1.18.11 (#65) — lab-result context block for the Coach snapshot. + * + * Lets the Coach answer "what was my LDL last time" / "is my ferritin in + * range" from the user's own LabResult rows instead of guessing. The block is + * SERVER-AUTHORITATIVE and GROUNDED: each biomarker carries the canonical + * name, value, unit, resolved reference bounds, the in/out-of-range verdict, + * and the measured date — all computed from the DB via the same resolver the + * Labs API + doctor-report PDF use (`resolveLabFields` / `classifyReferenceRange`), + * never re-derived. The Coach reads these numbers verbatim and must not invent + * a lab value the block does not carry. + * + * Bounding (no unbounded history dump): + * - MOST RECENT reading per biomarker only (one row per analyte). + * - Last `LOOKBACK_MONTHS` months — stale panels do not enter the prompt. + * - At most `MAX_BIOMARKERS` biomarkers, newest reading first, so a heavy + * history can never balloon the prompt. + * + * Qualitative readings (`value` null, `valueText` "negativ" / …) are included + * with a neutral `"unknown"` range status — there is nothing to compare against + * the bounds, so no in/out verdict is fabricated. The free-text `valueText` is + * the one user-controlled string here, so it is sanitised (control chars + + * newlines collapsed, length bounded) before it can reach the prompt, mirroring + * the illness block's label handling. The encrypted `noteEncrypted` column is + * never selected — the decrypted note must not reach the prompt. + * + * `userId` is narrowed from the authenticated session by the caller and feeds + * the Prisma `where` field-by-field; it is never an input. Unlike the illness + * block, Labs is intentionally NOT module-gated (mirrors `/api/labs` — the data + * is always owner-scoped and safe to read), so the block is `null` only when the + * account has no recent readings. + */ +import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { resolveLabFields } from "@/lib/labs/serialise"; +import { prisma } from "@/lib/db"; + +/** Only readings within this many months enter the prompt. */ +const LOOKBACK_MONTHS = 12; + +/** Cap on distinct biomarkers in the block (newest reading first). */ +const MAX_BIOMARKERS = 24; + +/** Max chars of the user-supplied qualitative `valueText` that may enter the prompt. */ +const MAX_VALUE_TEXT_CHARS = 60; + +/** + * Cap + strip the user-supplied qualitative result text before it enters the + * Coach LLM prompt. `valueText` is the one user-controlled string in this + * block, so it is a (self-scoped) prompt-injection surface: collapse control + * chars + newlines to spaces so an embedded "ignore previous instructions\n…" + * can't reshape the prompt structure, then bound the length. Mirrors + * `sanitizeLabel` in `illness-snapshot.ts`. + */ +export function sanitizeValueText(text: string): string { + let out = ""; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch; + } + return out.replace(/\s+/g, " ").trim().slice(0, MAX_VALUE_TEXT_CHARS); +} + +/** One biomarker's most-recent resolved reading. */ +export interface CoachLabReading { + /** Canonical analyte name (resolved from the linked biomarker when present). */ + analyte: string; + /** Panel grouping, when the resolved marker carries one. */ + panel: string | null; + /** Numeric reading; null for a qualitative row (see `valueText`). */ + value: number | null; + /** Qualitative result text ("negativ" / …); null for a numeric row. */ + valueText: string | null; + /** Resolved unit; empty for a qualitative marker. */ + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + /** in-range / below / above / unknown — computed from the resolved bounds. */ + rangeStatus: "in-range" | "below" | "above" | "unknown"; + /** Measured date (ISO). */ + takenAt: string; +} + +export interface CoachLabsBlock { + /** Most-recent reading per biomarker, newest first; bounded. */ + recent: CoachLabReading[]; +} + +/** + * Build the labs context block, or `null` when the account has no readings in + * the lookback window. Selects label + value + resolved bounds + dates only — + * `noteEncrypted` is never selected. + */ +export async function buildLabsSnapshotBlock( + userId: string, + now: Date = new Date(), +): Promise { + const cutoff = new Date(now); + cutoff.setMonth(cutoff.getMonth() - LOOKBACK_MONTHS); + + // Newest reading first; we keep only the first row seen per biomarker so the + // block carries the MOST RECENT value per analyte. Bounded fetch: at most a + // reading per capped biomarker plus headroom for older duplicates of the same + // marker. Field-by-field `where` — never a spread. + const rows = await prisma.labResult.findMany({ + where: { + userId, + deletedAt: null, + takenAt: { gte: cutoff, lte: now }, + }, + orderBy: { takenAt: "desc" }, + take: MAX_BIOMARKERS * 8, + select: { + analyte: true, + panel: true, + value: true, + valueText: true, + unit: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + biomarkerId: true, + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }, + }, + }); + + if (rows.length === 0) return null; + + // De-dup to the most-recent reading per biomarker. Key on the linked + // biomarker id when present (the canonical identity); fall back to the + // lower-cased resolved analyte for any legacy unlinked row so two spellings + // of the same marker still collapse. Rows arrive newest-first, so the first + // key sighting wins. + const seen = new Set(); + const recent: CoachLabReading[] = []; + for (const row of rows) { + const resolved = resolveLabFields(row, row.biomarker); + const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; + if (seen.has(key)) continue; + seen.add(key); + + recent.push({ + analyte: resolved.analyte, + panel: resolved.panel, + value: row.value, + valueText: row.valueText ? sanitizeValueText(row.valueText) : null, + unit: resolved.unit, + referenceLow: resolved.referenceLow, + referenceHigh: resolved.referenceHigh, + // A qualitative row has nothing to compare against the bounds → neutral + // "unknown", never a fabricated verdict. A numeric row classifies. + rangeStatus: + row.value === null + ? "unknown" + : classifyReferenceRange( + row.value, + resolved.referenceLow, + resolved.referenceHigh, + ), + takenAt: row.takenAt.toISOString(), + }); + + if (recent.length >= MAX_BIOMARKERS) break; + } + + if (recent.length === 0) return null; + + return { recent }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 1292ed9f7..60d5177ed 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -49,6 +49,7 @@ import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; import { buildIllnessSnapshotBlock } from "./illness-snapshot"; +import { buildLabsSnapshotBlock } from "./labs-snapshot"; import { buildReferenceGroundingBlock, type GroundingMetricInput, @@ -1255,6 +1256,14 @@ async function buildCoachSnapshotImpl( // degrader never sheds it — the Coach needs to know about Rest Mode. const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now); + // v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a + // scope-gated metric: attached without a `registerBlock` so the budget + // degrader never sheds it, and Labs is intentionally not module-gated (the + // helper reads owner-scoped rows directly, mirroring `/api/labs`). The block + // carries the most-recent resolved reading per biomarker (last 12 months, + // capped) so the Coach can answer "what was my LDL" without re-deriving. + const labsBlockPromise = buildLabsSnapshotBlock(userId, now); + const [ moodRows, complianceMeds, @@ -1269,6 +1278,7 @@ async function buildCoachSnapshotImpl( memoryBlock, cycleBlock, illnessBlock, + labsBlock, ] = await Promise.all([ moodRowsPromise, complianceMedsPromise, @@ -1283,6 +1293,7 @@ async function buildCoachSnapshotImpl( memoryBlockPromise, cycleBlockPromise, illnessBlockPromise, + labsBlockPromise, ]); const byType = (t: string) => @@ -2273,6 +2284,15 @@ async function buildCoachSnapshotImpl( snapshot.illness = illnessBlock; } + // v1.18.11 (#65) — lab-result context. Attached WITHOUT a cluster + // registration (like illness/scope): the budget degrader never sheds it, so + // the Coach can always answer a lab question from the user's own readings. + // Server-authoritative + grounded — resolved name/value/unit/range per + // biomarker, never the decrypted note. + if (labsBlock) { + snapshot.labs = labsBlock; + } + 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 35aad7aad..74b3590d3 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -313,6 +313,19 @@ 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. +- 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, + takenAt }. Numeric readings carry "value" + "unit"; qualitative ones + ("negativ" / …) carry "valueText" with "value" null. "rangeStatus" is + the server's verdict (in-range / below / above / unknown) against the + resolved reference bounds — read it as given, never recompute it. + Answer lab questions ONLY from this block: quote the value, unit, range, + and date verbatim, and never invent a lab number it does not carry. The + list is the latest value per marker over the last year, not a full + history. Frame results descriptively, never as a diagnosis. When no + "labs" block is present, you have no lab values — say so plainly rather + than guess. EVIDENCE BLOCK @@ -687,6 +700,19 @@ 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. +- 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, + takenAt }. Numerische Werte tragen "value" + "unit"; qualitative + ("negativ" / …) tragen "valueText" bei "value" null. "rangeStatus" ist das + serverseitige Urteil (in-range / below / above / unknown) gegen die + aufgelösten Referenzgrenzen — lies es wie angegeben, berechne es nie neu. + Beantworte Laborfragen NUR aus diesem Block: zitiere Wert, Einheit, + Bereich und Datum wörtlich und erfinde nie einen Laborwert, den der Block + nicht enthält. Die Liste ist der jeweils letzte Wert pro Marker aus dem + letzten Jahr, keine vollständige Historie. Formuliere Ergebnisse + beschreibend, nie als Diagnose. Fehlt der "labs"-Block, hast du keine + Laborwerte — sage das klar, statt zu raten. EVIDENZ-BLOCK