From 190a83c513b45745e4d15cc9acf531afea73f9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:50:56 +0200 Subject: [PATCH] feat(coach): read lab results over the whole record The Coach read lab results from the last twelve months only, even with the lookback set to its widest option, so a slow change in LDL or PSA across years stayed out of reach. Labs are sparse, so they are no longer held to the one-year window that bounds the dense readings: they reach back over the whole record, and a lookback limit the person set still applies. The snapshot block keeps the latest value per marker, now over that wider window, at its old size: it shares the prompt's size cap and is never shed. get_labs with an analyte adds each matching marker's history, summarised so it stays bounded: the newest five earlier readings in full and older ones as one mean / min / max / count per year, ten years at most, older years counted. With a heavy fixture (30 markers, quarterly for 20 years) the overview stays at 4.3k characters and a named LDL read grows from 0.2k to 1.2k characters for 80 readings. The MCP get_labs read and the lab resource follow, since they run the same tool. The widest lookback option no longer reads "All": it names what it reads, twelve months in detail and every lab result, in every locale. Refs #1263 --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/nl.json | 4 +- messages/pl.json | 4 +- .../__tests__/sources-rail.test.tsx | 2 +- .../__tests__/coach-history-reach.test.ts | 10 +- .../coach/__tests__/lab-history-shape.test.ts | 88 ++++++ .../ai/coach/__tests__/labs-snapshot.test.ts | 54 +++- .../ai/coach/__tests__/tools-executor.test.ts | 25 +- src/lib/ai/coach/history-reach.ts | 16 +- src/lib/ai/coach/labs-snapshot.ts | 261 ++++++++++++++---- src/lib/ai/coach/system-prompt.ts | 28 +- src/lib/ai/coach/tools/definitions.ts | 4 +- src/lib/ai/coach/tools/executor.ts | 37 ++- src/lib/mcp/resources.ts | 2 +- src/lib/mcp/tools.ts | 2 +- tests/integration/coach-labs-history.test.ts | 144 ++++++++++ 21 files changed, 586 insertions(+), 119 deletions(-) create mode 100644 src/lib/ai/coach/__tests__/lab-history-shape.test.ts create mode 100644 tests/integration/coach-labs-history.test.ts diff --git a/messages/de.json b/messages/de.json index e984dd762..26b8abbcb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3231,11 +3231,11 @@ "last30days": "{days} Tage", "last90days": "{days} Tage", "lastYear": "{months} Monate", - "allTime": "Alles" + "allTime": "{months} Monate im Detail, alle Laborwerte" }, "detailDays": "Der Coach liest nichts, was älter als {days} Tage ist, in keiner Antwort.", "detailMonths": "Der Coach liest nichts, was älter als {months} Monate ist, in keiner Antwort.", - "detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück, Laborwerte der letzten {labMonths} Monate.", + "detailAll": "Alles, was du erfasst hast: {dailyMonths} Monate tagesgenau, ältere Werte als Monatswerte bis {years} Jahre zurück und alle Laborwerte, ältere pro Jahr zusammengefasst.", "note": "Dein Profil, aktuelle Medikamente, eine laufende Erkrankung und anstehende Termine bleiben sichtbar; Auswertungen, die mehr Verlauf brauchen, werden gekürzt oder weggelassen. Antworten, die der Coach früher in älteren Unterhaltungen gegeben hat, können noch ältere Werte nennen." }, "showConversations": "Unterhaltungen anzeigen", diff --git a/messages/en.json b/messages/en.json index 78069ee50..e2c3a9223 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3231,11 +3231,11 @@ "last30days": "{days} days", "last90days": "{days} days", "lastYear": "{months} months", - "allTime": "All" + "allTime": "{months} months in detail, all lab results" }, "detailDays": "The Coach reads nothing older than {days} days, in any answer.", "detailMonths": "The Coach reads nothing older than {months} months, in any answer.", - "detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, lab results from the last {labMonths} months.", + "detailAll": "Everything you recorded: {dailyMonths} months in daily detail, older readings as monthly values up to {years} years back, and every lab result, older ones summarised per year.", "note": "Your profile, current medications, an ongoing illness and upcoming visits stay visible; summaries that need more history are shortened or left out. Answers the Coach gave earlier in older conversations may still quote older values." }, "showConversations": "Show conversations", diff --git a/messages/es.json b/messages/es.json index 85db098d5..d5d8e381d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3231,11 +3231,11 @@ "last30days": "{days} días", "last90days": "{days} días", "lastYear": "{months} meses", - "allTime": "Todo" + "allTime": "{months} meses en detalle, todos los análisis" }, "detailDays": "El Coach no lee nada con más de {days} días de antigüedad, en ninguna respuesta.", "detailMonths": "El Coach no lee nada con más de {months} meses de antigüedad, en ninguna respuesta.", - "detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás, los análisis de los últimos {labMonths} meses.", + "detailAll": "Todo lo que has registrado: {dailyMonths} meses al detalle diario, las mediciones más antiguas como valores mensuales hasta {years} años atrás y todos los análisis, los más antiguos resumidos por año.", "note": "Tu perfil, tu medicación actual, una enfermedad en curso y las próximas citas siguen visibles; los resúmenes que necesitan más historial se acortan o se omiten. Las respuestas que el Coach dio antes en conversaciones anteriores pueden seguir citando valores más antiguos." }, "showConversations": "Mostrar conversaciones", diff --git a/messages/fr.json b/messages/fr.json index 78d873360..0c71c13fc 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3231,11 +3231,11 @@ "last30days": "{days} jours", "last90days": "{days} jours", "lastYear": "{months} mois", - "allTime": "Tout" + "allTime": "{months} mois en détail, toutes les analyses" }, "detailDays": "Le Coach ne lit rien de plus ancien que {days} jours, dans aucune réponse.", "detailMonths": "Le Coach ne lit rien de plus ancien que {months} mois, dans aucune réponse.", - "detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, les analyses des {labMonths} derniers mois.", + "detailAll": "Tout ce que vous avez enregistré : {dailyMonths} mois au jour près, les mesures plus anciennes en valeurs mensuelles jusqu’à {years} ans en arrière, et toutes les analyses, les plus anciennes résumées par année.", "note": "Votre profil, vos médicaments actuels, une maladie en cours et vos prochains rendez-vous restent visibles ; les synthèses qui demandent plus d’historique sont raccourcies ou omises. Les réponses données plus tôt par le Coach dans d’anciennes conversations peuvent encore citer des valeurs plus anciennes." }, "showConversations": "Afficher les conversations", diff --git a/messages/it.json b/messages/it.json index e288b3d89..05b54c2ad 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3231,11 +3231,11 @@ "last30days": "{days} giorni", "last90days": "{days} giorni", "lastYear": "{months} mesi", - "allTime": "Tutto" + "allTime": "{months} mesi in dettaglio, tutti gli esami" }, "detailDays": "Il Coach non legge nulla di più vecchio di {days} giorni, in nessuna risposta.", "detailMonths": "Il Coach non legge nulla di più vecchio di {months} mesi, in nessuna risposta.", - "detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa, gli esami degli ultimi {labMonths} mesi.", + "detailAll": "Tutto ciò che hai registrato: {dailyMonths} mesi giorno per giorno, le misurazioni più vecchie come valori mensili fino a {years} anni fa e tutti gli esami, quelli più vecchi riassunti per anno.", "note": "Il tuo profilo, i farmaci attuali, una malattia in corso e le prossime visite restano visibili; i riepiloghi che richiedono più storico vengono accorciati o omessi. Le risposte date in precedenza dal Coach nelle conversazioni più vecchie possono ancora citare valori più vecchi." }, "showConversations": "Mostra conversazioni", diff --git a/messages/ko.json b/messages/ko.json index d825c089e..837004eed 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -3231,11 +3231,11 @@ "last30days": "{days}일", "last90days": "{days}일", "lastYear": "{months}개월", - "allTime": "전체" + "allTime": "{months}개월 상세, 모든 검사 결과" }, "detailDays": "코치는 어떤 답변에서도 {days}일보다 오래된 기록은 읽지 않아요.", "detailMonths": "코치는 어떤 답변에서도 {months}개월보다 오래된 기록은 읽지 않아요.", - "detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 최근 {labMonths}개월 치를 읽어요.", + "detailAll": "기록한 모든 것: 최근 {dailyMonths}개월은 하루 단위로, 그 이전 측정값은 최대 {years}년 전까지 월별 값으로, 검사 결과는 전부 읽고 오래된 결과는 연도별로 요약해요.", "note": "프로필, 현재 복용 중인 약, 진행 중인 질병, 예정된 진료는 계속 보여요. 더 긴 기록이 필요한 요약은 줄이거나 빼요. 예전 대화에서 코치가 이미 한 답변에는 더 오래된 값이 남아 있을 수 있어요." }, "showConversations": "대화 보기", diff --git a/messages/nl.json b/messages/nl.json index 41eb4eb58..ce6a13521 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -3231,11 +3231,11 @@ "last30days": "{days} dagen", "last90days": "{days} dagen", "lastYear": "{months} maanden", - "allTime": "Alles" + "allTime": "{months} maanden in detail, alle labuitslagen" }, "detailDays": "De Coach leest in geen enkel antwoord iets dat ouder is dan {days} dagen.", "detailMonths": "De Coach leest in geen enkel antwoord iets dat ouder is dan {months} maanden.", - "detailAll": "Alles wat je hebt vastgelegd: {dailyMonths} maanden per dag, oudere metingen als maandwaarden tot {years} jaar terug en labuitslagen van de afgelopen {labMonths} maanden.", + "detailAll": "Alles wat je hebt vastgelegd: {dailyMonths} maanden per dag, oudere metingen als maandwaarden tot {years} jaar terug en alle labuitslagen, oudere per jaar samengevat.", "note": "Je profiel, je huidige medicatie, een lopende ziekte en komende afspraken blijven zichtbaar; samenvattingen die meer geschiedenis nodig hebben, worden ingekort of weggelaten. Antwoorden die de Coach eerder in oudere gesprekken gaf, kunnen nog oudere waarden noemen." }, "showConversations": "Gesprekken tonen", diff --git a/messages/pl.json b/messages/pl.json index da6829de7..da43b2d91 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3231,11 +3231,11 @@ "last30days": "{days} dni", "last90days": "{days} dni", "lastYear": "{months} miesięcy", - "allTime": "Wszystko" + "allTime": "{months} miesięcy szczegółowo, wszystkie wyniki badań" }, "detailDays": "Coach nie czyta niczego starszego niż {days} dni, w żadnej odpowiedzi.", "detailMonths": "Coach nie czyta niczego starszego niż {months} miesięcy, w żadnej odpowiedzi.", - "detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz, wyniki badań z ostatnich {labMonths} miesięcy.", + "detailAll": "Wszystko, co zostało zapisane: {dailyMonths} miesięcy z dokładnością do dnia, starsze pomiary jako wartości miesięczne do {years} lat wstecz oraz wszystkie wyniki badań, starsze podsumowane rocznie.", "note": "Twój profil, obecne leki, trwająca choroba i nadchodzące wizyty pozostają widoczne; podsumowania wymagające dłuższej historii są skracane lub pomijane. Odpowiedzi, których Coach udzielił wcześniej w starszych rozmowach, mogą nadal przytaczać starsze wartości." }, "showConversations": "Pokaż rozmowy", diff --git a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx index f616b2b07..c6d716c45 100644 --- a/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx +++ b/src/components/insights/coach-panel/__tests__/sources-rail.test.tsx @@ -151,7 +151,7 @@ describe("", () => { // `allTime` is the default: the sentence carries the numbers the // readers use (`history-reach.ts`). expect(html).toContain( - "Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, lab results from the last 12 months.", + "Everything you recorded: 12 months in daily detail, older readings as monthly values up to 10 years back, and every lab result, older ones summarised per year.", ); expect(html).not.toContain("stay visible"); }); diff --git a/src/lib/ai/coach/__tests__/coach-history-reach.test.ts b/src/lib/ai/coach/__tests__/coach-history-reach.test.ts index a947c294f..e8dd8b35a 100644 --- a/src/lib/ai/coach/__tests__/coach-history-reach.test.ts +++ b/src/lib/ai/coach/__tests__/coach-history-reach.test.ts @@ -121,10 +121,12 @@ describe("the numbers the settings text states", () => { detailParams: { days: 90 }, }); expect(lookbackText("lastYear").detailParams).toEqual({ months: 12 }); - expect(lookbackText("allTime").detailParams).toEqual({ - dailyMonths: 12, - years: 10, - labMonths: 12, + expect(lookbackText("allTime")).toEqual({ + // "12 months in detail, every lab result": the option says what it + // reads, not "All" (#1263). + optionParams: { months: 12 }, + detailKey: "detailAll", + detailParams: { dailyMonths: 12, years: 10 }, }); }); }); diff --git a/src/lib/ai/coach/__tests__/lab-history-shape.test.ts b/src/lib/ai/coach/__tests__/lab-history-shape.test.ts new file mode 100644 index 000000000..41969a276 --- /dev/null +++ b/src/lib/ai/coach/__tests__/lab-history-shape.test.ts @@ -0,0 +1,88 @@ +/** + * The bound on a biomarker's history in the Coach labs block (#1263). + * + * Labs reach back over the whole record now, so a marker measured four times + * a year for twenty years must not put eighty readings into the prompt. The + * newest few earlier readings go in full, older ones fold into one entry per + * calendar year, and only the newest years are listed. + */ +import { describe, expect, it } from "vitest"; + +import { + LAB_HISTORY_MAX_YEARS, + LAB_HISTORY_RECENT_POINTS, + shapeLabHistory, + type LabHistoryInput, +} from "../labs-snapshot"; + +const LATEST = new Date("2026-09-15T08:00:00.000Z"); + +/** Quarterly readings going back `years`, newest first, LATEST excluded. */ +function quarterly(years: number): LabHistoryInput[] { + const out: LabHistoryInput[] = []; + for (let q = 1; q <= years * 4; q++) { + const at = new Date(LATEST); + at.setUTCMonth(at.getUTCMonth() - 3 * q); + out.push({ value: 100 + q, takenAt: at, rangeStatus: "in-range" }); + } + return out; +} + +describe("shapeLabHistory", () => { + it("is null without earlier readings", () => { + expect(shapeLabHistory(LATEST, [])).toBeNull(); + }); + + it("lists a short history in full", () => { + const history = shapeLabHistory(LATEST, quarterly(1)); + expect(history?.count).toBe(5); + expect(history?.earlier).toHaveLength(4); + expect(history?.yearly).toEqual([]); + expect(history?.yearsOmitted).toBe(0); + }); + + it("folds a twenty-year history into a bounded summary", () => { + const earlier = quarterly(20); + const history = shapeLabHistory(LATEST, earlier)!; + + expect(history.count).toBe(earlier.length + 1); + expect(history.firstTakenAt).toBe(earlier.at(-1)!.takenAt.toISOString()); + expect(history.earlier).toHaveLength(LAB_HISTORY_RECENT_POINTS); + expect(history.earlier[0].value).toBe(earlier[0].value); + expect(history.yearly).toHaveLength(LAB_HISTORY_MAX_YEARS); + // Newest year first, and every folded reading is accounted for. + const years = history.yearly.map((entry) => entry.year); + expect(years).toEqual([...years].sort((a, b) => b - a)); + const listedYears = new Set( + earlier + .slice(LAB_HISTORY_RECENT_POINTS) + .map((r) => r.takenAt.getUTCFullYear()), + ); + expect(history.yearsOmitted).toBe(listedYears.size - LAB_HISTORY_MAX_YEARS); + }); + + it("summarises a year as mean, min, max and count of its readings", () => { + const year = (value: number, month: number): LabHistoryInput => ({ + value, + takenAt: new Date(Date.UTC(2019, month, 1)), + rangeStatus: "above", + }); + const recent = Array.from( + { length: LAB_HISTORY_RECENT_POINTS }, + (_, i) => ({ + value: 90, + takenAt: new Date(Date.UTC(2025, 11 - i, 1)), + rangeStatus: "in-range" as const, + }), + ); + const history = shapeLabHistory(LATEST, [ + ...recent, + year(160, 10), + year(150, 6), + year(141, 2), + ])!; + expect(history.yearly).toEqual([ + { year: 2019, count: 3, mean: 150.33, min: 141, max: 160 }, + ]); + }); +}); diff --git a/src/lib/ai/coach/__tests__/labs-snapshot.test.ts b/src/lib/ai/coach/__tests__/labs-snapshot.test.ts index b3dfbd5dc..aa1063b64 100644 --- a/src/lib/ai/coach/__tests__/labs-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/labs-snapshot.test.ts @@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({ })); import { buildLabsSnapshotBlock, sanitizeValueText } from "../labs-snapshot"; +import { UNBOUNDED_REACH } from "../history-reach"; import { prisma } from "@/lib/db"; const prismaMock = prisma as unknown as { @@ -113,6 +114,54 @@ describe("buildLabsSnapshotBlock", () => { const block = await buildLabsSnapshotBlock("user_1", NOW); expect(block!.recent).toHaveLength(1); expect(block!.recent[0].value).toBe(120); + // The snapshot block carries no history (#1263): it shares the prompt cap. + expect(block!.recent[0].history).toBeUndefined(); + }); + + it("adds the earlier readings as history for a named analyte", async () => { + 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, UNBOUNDED_REACH, { + analyte: "ldl", + }); + expect(block!.recent[0].history).toEqual({ + count: 2, + firstTakenAt: "2026-01-10T08:00:00.000Z", + earlier: [ + { + value: 145, + rangeStatus: "above", + takenAt: "2026-01-10T08:00:00.000Z", + }, + ], + yearly: [], + yearsOmitted: 0, + }); + }); + + it("leaves a reading stored in another unit out of the history", async () => { + prismaMock.labResult.findMany.mockResolvedValue([ + numericRow({ value: 120, takenAt: new Date("2026-06-10T08:00:00.000Z") }), + numericRow({ + value: 3.1, + takenAt: new Date("2025-06-10T08:00:00.000Z"), + biomarker: { + id: "bm_ldl", + name: "LDL", + unit: "mmol/L", + lowerBound: 0, + upperBound: 3.4, + panel: "Lipids", + }, + }), + ]); + const block = await buildLabsSnapshotBlock("user_1", NOW, UNBOUNDED_REACH, { + analyte: "LDL", + }); + expect(block!.recent[0].value).toBe(120); + expect(block!.recent[0].history).toBeUndefined(); }); it("collapses two spellings of an unlinked marker by lower-cased analyte", async () => { @@ -147,14 +196,15 @@ describe("buildLabsSnapshotBlock", () => { expect(block!.recent[0].value).toBe(80); }); - it("queries with an owner-scoped, deletedAt-null, 12-month window where", async () => { + it("queries owner-scoped and deletedAt-null over the whole record", 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")); + // #1263 — labs are not held to a year without a lookback limit. + expect(arg.where.takenAt.gte).toBeUndefined(); // Never selects the encrypted note column. expect(arg.select.noteEncrypted).toBeUndefined(); }); diff --git a/src/lib/ai/coach/__tests__/tools-executor.test.ts b/src/lib/ai/coach/__tests__/tools-executor.test.ts index cc6569de5..e359bea4f 100644 --- a/src/lib/ai/coach/__tests__/tools-executor.test.ts +++ b/src/lib/ai/coach/__tests__/tools-executor.test.ts @@ -76,6 +76,15 @@ vi.mock("@/lib/ai/coach/illness-snapshot", () => ({ buildIllnessScores: (userId: string) => buildIllnessScores(userId), })); +// #1263 — a named labs read is its own read (latest + history per matching +// marker); the dispatch test stubs it like the snapshot. The read itself runs +// against Postgres in `tests/integration/coach-labs-history.test.ts`. +const buildLabsSnapshotBlock = vi.fn(); +vi.mock("@/lib/ai/coach/labs-snapshot", () => ({ + buildLabsSnapshotBlock: (...args: unknown[]) => + buildLabsSnapshotBlock(...args), +})); + import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; function snapshot( @@ -223,19 +232,27 @@ describe("executeCoachTool", () => { }); }); - it("filters labs by analyte and reports analyte_not_found", async () => { + it("reads a named analyte with its history and reports analyte_not_found", async () => { buildCoachSnapshot.mockResolvedValue( snapshot({ - labs: { recent: [{ name: "LDL", value: "120" }] }, + labs: { recent: [{ analyte: "LDL", value: 120 }] }, }), ); + buildLabsSnapshotBlock.mockImplementation( + async (_userId: string, _now: Date, _reach: unknown, options: unknown) => + (options as { analyte?: string }).analyte === "ldl" + ? { recent: [{ analyte: "LDL", value: 120, history: { count: 2 } }] } + : null, + ); const hit = await executeCoachTool({ userId: "u1", name: "get_labs", - rawArguments: JSON.stringify({ analyte: "ldl" }), + rawArguments: JSON.stringify({ analyte: "LDL" }), }); expect(hit.present).toBe(true); - expect(hit.data).toMatchObject({ recent: [{ name: "LDL" }] }); + expect(hit.data).toMatchObject({ + recent: [{ analyte: "LDL", history: { count: 2 } }], + }); const miss = await executeCoachTool({ userId: "u1", diff --git a/src/lib/ai/coach/history-reach.ts b/src/lib/ai/coach/history-reach.ts index 571a36ea1..0bfcb0d0c 100644 --- a/src/lib/ai/coach/history-reach.ts +++ b/src/lib/ai/coach/history-reach.ts @@ -5,8 +5,8 @@ * Before it became a limit, the saved window only seeded the snapshot and the default of * each tool call; every tool accepted its own `window` up to `allTime`, the * availability probe reported out-of-window history with counts and means, - * and several snapshot blocks read fixed windows (labs 12 months, glucose - * panel 30 days, correlations 180 days) whatever the setting said. The + * and several snapshot blocks read fixed windows (glucose panel 30 days, + * correlations 180 days) whatever the setting said. The * setting is now enforced everywhere the Coach reads: the snapshot, every * tool, the probe, the inventory and the fixed windows. Each reader takes a * `CoachHistoryReach` and either clamps its window to it (`clampWindow`, @@ -163,8 +163,8 @@ export const COACH_DAILY_DETAIL_DAYS = 365; /** How far back an all-time table reaches, as monthly values. */ export const COACH_ALL_TIME_TABLE_DAYS = 3_650; -/** Months of lab results the Coach reads (`labs-snapshot.ts`). */ -export const COACH_LABS_LOOKBACK_MONTHS = 12; +// Lab results are not held to a window of their own: without a limit the +// Coach reads all of them, summarised per marker (`labs-snapshot.ts`, #1263). /** The i18n key and parameters describing one lookback option. */ export function lookbackText(window: CoachDefaultWindow): { @@ -174,13 +174,15 @@ export function lookbackText(window: CoachDefaultWindow): { } { const days = HISTORY_REACH_DAYS[window]; if (days === null) { + const dailyMonths = Math.round(COACH_DAILY_DETAIL_DAYS / 30.4); + // The option names what "no limit" actually reads (#1263): the dense + // readings in daily detail for a year, the lab results in full. return { - optionParams: {}, + optionParams: { months: dailyMonths }, detailKey: "detailAll", detailParams: { - dailyMonths: Math.round(COACH_DAILY_DETAIL_DAYS / 30.4), + dailyMonths, years: Math.round(COACH_ALL_TIME_TABLE_DAYS / 365), - labMonths: COACH_LABS_LOOKBACK_MONTHS, }, }; } diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 119c299e0..a28effb09 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -11,10 +11,24 @@ * 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. + * - One entry per biomarker: its MOST RECENT reading, plus a `history` of the + * earlier numeric readings in the same unit when there are any. + * - The whole record by default (#1263). Labs are months or years apart, and + * a slow drift (LDL, PSA, HbA1c) is only visible across years, so they are + * not held to the one-year window that bounds the dense readings. The + * person's lookback limit still applies: under "12 months" the block reads + * twelve months, as before. + * - The history rides only a read for named analytes (`get_labs` with an + * `analyte`), never the snapshot: the snapshot shares one size cap with + * every other block and labs are never shed from it, so it keeps the + * latest reading per marker. A history is summarised, not dumped: the + * newest `LAB_HISTORY_RECENT_POINTS` earlier readings in full, everything + * older folded into one mean / min / max / count per calendar year, at + * most `LAB_HISTORY_MAX_YEARS` years (older years are counted, not + * listed). + * - At most `MAX_BIOMARKERS` biomarkers, the most recently measured first, + * and at most `MAX_ROWS` rows read, 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 @@ -38,9 +52,8 @@ * account has no recent readings. */ import { - COACH_LABS_LOOKBACK_MONTHS, UNBOUNDED_REACH, - laterOfFloor, + reachFloor, type CoachHistoryReach, } from "./history-reach"; import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range"; @@ -48,12 +61,22 @@ import { resolveLabFields } from "@/lib/labs/serialise"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { prisma } from "@/lib/db"; -/** Only readings within this many months enter the prompt. */ -const LOOKBACK_MONTHS = COACH_LABS_LOOKBACK_MONTHS; - /** Cap on distinct biomarkers in the block (newest reading first). */ const MAX_BIOMARKERS = 24; +/** Earlier readings per biomarker carried in full, beside the latest. */ +export const LAB_HISTORY_RECENT_POINTS = 5; + +/** Calendar years of yearly aggregates per biomarker, newest first. */ +export const LAB_HISTORY_MAX_YEARS = 10; + +/** + * Rows the block reads at most, newest first. A guard rather than a budget: + * forty markers four times a year for twenty years is 3 200 rows, and the + * shaping below keeps the prompt bounded whatever comes back. + */ +const MAX_ROWS = 4_000; + /** Max chars of the user-supplied qualitative `valueText` that may enter the prompt. */ const MAX_VALUE_TEXT_CHARS = 60; @@ -100,9 +123,96 @@ export interface CoachLabReading { takenAt: string; } +/** One earlier numeric reading of a biomarker. */ +export interface CoachLabHistoryPoint { + value: number; + /** in-range / below / above / unknown against that reading's own bounds. */ + rangeStatus: "in-range" | "below" | "above" | "unknown"; + takenAt: string; +} + +/** The readings of one calendar year (UTC), folded. */ +export interface CoachLabYear { + year: number; + count: number; + mean: number; + min: number; + max: number; +} + +/** + * The earlier numeric readings of a biomarker, in the unit of its latest + * reading (a reading stored in another unit is left out rather than mixed in). + */ +export interface CoachLabHistory { + /** Numeric readings in the window, the latest included. */ + count: number; + /** The oldest of them. */ + firstTakenAt: string; + /** The newest earlier readings in full, newest first, the latest excluded. */ + earlier: CoachLabHistoryPoint[]; + /** The readings older than `earlier`, one entry per year, newest first. */ + yearly: CoachLabYear[]; + /** Years older than the listed ones that hold readings, counted only. */ + yearsOmitted: number; +} + export interface CoachLabsBlock { /** Most-recent reading per biomarker, newest first; bounded. */ - recent: CoachLabReading[]; + recent: Array; +} + +/** One numeric reading as the history shaping sees it. */ +export interface LabHistoryInput { + value: number; + takenAt: Date; + rangeStatus: CoachLabHistoryPoint["rangeStatus"]; +} + +const round2 = (value: number) => Math.round(value * 100) / 100; + +/** + * Shape a biomarker's earlier readings (newest first, the latest excluded) + * into the bounded `history`. `null` when there are none. Pure, so the bound + * is testable without a database. + */ +export function shapeLabHistory( + latestTakenAt: Date, + earlier: readonly LabHistoryInput[], +): CoachLabHistory | null { + if (earlier.length === 0) return null; + const listed = earlier.slice(0, LAB_HISTORY_RECENT_POINTS); + const folded = earlier.slice(LAB_HISTORY_RECENT_POINTS); + const byYear = new Map(); + for (const reading of folded) { + const year = reading.takenAt.getUTCFullYear(); + const values = byYear.get(year); + if (values) values.push(reading.value); + else byYear.set(year, [reading.value]); + } + const years = [...byYear.keys()].sort((a, b) => b - a); + const yearly = years.slice(0, LAB_HISTORY_MAX_YEARS).map((year) => { + const values = byYear.get(year) ?? []; + return { + year, + count: values.length, + mean: round2(values.reduce((sum, v) => sum + v, 0) / values.length), + min: Math.min(...values), + max: Math.max(...values), + }; + }); + const oldest = earlier[earlier.length - 1]?.takenAt ?? latestTakenAt; + return { + count: earlier.length + 1, + firstTakenAt: oldest.toISOString(), + earlier: listed.map((reading) => ({ + value: reading.value, + rangeStatus: reading.rangeStatus, + takenAt: reading.takenAt.toISOString(), + })), + yearly, + yearsOmitted: Math.max(0, years.length - LAB_HISTORY_MAX_YEARS), + }; } /** @@ -113,25 +223,31 @@ export interface CoachLabsBlock { export async function buildLabsSnapshotBlock( userId: string, now: Date = new Date(), - /** The Coach's lookback limit: the shorter of it and the 12 months wins. */ + /** The Coach's lookback limit; without one the whole record is read. */ reach: CoachHistoryReach = UNBOUNDED_REACH, + options: { + /** + * Only the markers whose resolved name contains this text (lower-cased), + * matched before the marker cap so a rarely measured one is still found. + * With it, each entry carries its `history`. + */ + analyte?: string; + } = {}, ): Promise { - const monthsBack = new Date(now); - monthsBack.setMonth(monthsBack.getMonth() - LOOKBACK_MONTHS); - const cutoff = laterOfFloor(monthsBack, reach, now); + const wanted = options.analyte?.trim().toLowerCase() || null; + const floor = reachFloor(reach, now); - // 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. + // Newest reading first: the first row seen per biomarker is its MOST RECENT + // value, every later row of it feeds its history. Field-by-field `where` — + // never a spread. const rows = await prisma.labResult.findMany({ where: { userId, deletedAt: null, - takenAt: { gte: cutoff, lte: now }, + takenAt: floor ? { gte: floor, lte: now } : { lte: now }, }, orderBy: { takenAt: "desc" }, - take: MAX_BIOMARKERS * 8, + take: MAX_ROWS, select: { analyte: true, panel: true, @@ -165,46 +281,83 @@ export async function buildLabsSnapshotBlock( // 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[] = []; + // key sighting is the latest reading and every later one is history. + const entries = new Map< + string, + { + reading: CoachLabReading; + unit: string; + takenAt: Date; + earlier: LabHistoryInput[]; + } + >(); for (const row of rows) { const resolved = resolveLabFields(row, row.biomarker); + if (wanted && !resolved.analyte.toLowerCase().includes(wanted)) continue; const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; - if (seen.has(key)) continue; - seen.add(key); + const existing = entries.get(key); + if (existing) { + if (wanted && row.value !== null && resolved.unit === existing.unit) { + existing.earlier.push({ + value: row.value, + takenAt: row.takenAt, + rangeStatus: classifyAgainstEffectiveRange( + row.value, + resolved.effectiveRange, + ), + }); + } + continue; + } + if (entries.size >= MAX_BIOMARKERS) continue; - recent.push({ - // v1.30.25 — analyte / panel / unit are NOT server-authored. A lab row - // committed from an uploaded document carries the name a model - // transcribed out of that PDF (`resolveOrMintBiomarker` stores it - // verbatim as `Biomarker.name`), so a hostile document chooses this - // string. It reaches BOTH the snapshot prompt and the `labs_read` tool - // result, so it is sanitised HERE at the shared source rather than at - // either consumer. `sanitizeForPrompt` strips control chars and the - // instruction-shaped patterns; the fence around the block carries the - // data/instruction contract. - analyte: sanitizeForPrompt(resolved.analyte, MAX_ANALYTE_CHARS), - panel: resolved.panel - ? sanitizeForPrompt(resolved.panel, MAX_ANALYTE_CHARS) - : null, - value: row.value, - valueText: row.valueText ? sanitizeValueText(row.valueText) : null, - unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS), - 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 against - // the window in force for it — the report's own range when it carries - // one, the catalog band otherwise. Same rule as every other lab surface. - rangeStatus: classifyAgainstEffectiveRange( - row.value, - resolved.effectiveRange, - ), - takenAt: row.takenAt.toISOString(), + entries.set(key, { + unit: resolved.unit, + takenAt: row.takenAt, + earlier: [], + reading: { + // v1.30.25 — analyte / panel / unit are NOT server-authored. A lab row + // committed from an uploaded document carries the name a model + // transcribed out of that PDF (`resolveOrMintBiomarker` stores it + // verbatim as `Biomarker.name`), so a hostile document chooses this + // string. It reaches BOTH the snapshot prompt and the `labs_read` + // tool result, so it is sanitised HERE at the shared source rather + // than at either consumer. `sanitizeForPrompt` strips control chars + // and the instruction-shaped patterns; the fence around the block + // carries the data/instruction contract. + analyte: sanitizeForPrompt(resolved.analyte, MAX_ANALYTE_CHARS), + panel: resolved.panel + ? sanitizeForPrompt(resolved.panel, MAX_ANALYTE_CHARS) + : null, + value: row.value, + valueText: row.valueText ? sanitizeValueText(row.valueText) : null, + unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS), + 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 against the window in force for it — the report's own + // range when it carries one, the catalog band otherwise. Same rule as + // every other lab surface. + rangeStatus: classifyAgainstEffectiveRange( + row.value, + resolved.effectiveRange, + ), + takenAt: row.takenAt.toISOString(), + }, }); + } - if (recent.length >= MAX_BIOMARKERS) break; + const recent: CoachLabsBlock["recent"] = []; + for (const entry of entries.values()) { + // History is numeric and in the latest reading's unit; a qualitative + // latest reading has no unit to hold the earlier values to. Collected + // only for a named read (see the header). + const history = + wanted && entry.reading.value !== null + ? shapeLabHistory(entry.takenAt, entry.earlier) + : null; + recent.push(history ? { ...entry.reading, history } : entry.reading); } if (recent.length === 0) return null; diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 737c2329c..5815c5064 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -442,10 +442,15 @@ ISO-week means. 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. + block holds the latest value per marker only; a get_labs result for a + named analyte MAY add "history": { count, firstTakenAt, earlier[] (the + newest earlier readings, each { value, rangeStatus, takenAt }), yearly[] + (older readings folded per calendar year, each { year, count, mean, min, + max }), yearsOmitted } — all in the entry's unit. Use it for a change over + time, quoting its values and years as given; a yearly mean is a summary of + that year, not a reading taken on a date. 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 @@ -967,10 +972,17 @@ ISO-Wochenmittel zusammen. 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. + nicht enthält. Der Block trägt nur den neuesten Wert pro Marker; ein + get_labs-Ergebnis für einen benannten Analyten KANN "history" ergänzen: + { count, firstTakenAt, earlier[] (die neuesten früheren Werte, je { value, + rangeStatus, takenAt }), yearly[] (ältere Werte pro Kalenderjahr + zusammengefasst, je { year, count, mean, min, max }), yearsOmitted } — + alles in der Einheit des Eintrags. + Nutze sie für einen Verlauf über die Zeit und zitiere Werte und Jahre wie + angegeben; ein Jahresmittel fasst ein Jahr zusammen und ist kein Messwert + an einem Datum. Formuliere Ergebnisse beschreibend, nie als Diagnose. + Fehlt der "labs"-Block, hast du keine Laborwerte — sage das klar, statt zu + raten. EVIDENZ-BLOCK diff --git a/src/lib/ai/coach/tools/definitions.ts b/src/lib/ai/coach/tools/definitions.ts index 60abf8b7c..41b181ac4 100644 --- a/src/lib/ai/coach/tools/definitions.ts +++ b/src/lib/ai/coach/tools/definitions.ts @@ -14,7 +14,7 @@ * 2. get_glucose_panel — per-context daily means + the 30-day clinical panel * 3. get_sleep — per-night sleep + sleep-rhythm (debt + chronotype) * 4. get_medication_compliance — dose-weighted compliance + GLP-1 - * 5. get_labs — latest reading per biomarker (12 months) + * 5. get_labs — latest reading per biomarker + history * 6. get_illness_recovery — restMode + active/resolved illnesses + recovery composites * 7. get_workouts — recent sessions + per-sport rollup (v1.21.0, C2-4) * 8. get_cycle — menstrual phase / prediction / correlation (v1.21.0, C2-1) @@ -321,7 +321,7 @@ export const COACH_TOOL_DEFS: AiToolDef[] = [ { name: "get_labs", description: - "Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months (or the user's lookback limit, when shorter), or one named analyte. Returns { present: false } with a reason — no_data means no labs are on file, outside_window means panels exist but are older than the twelve months this tool reads, outside_reach means they are older than the user's lookback limit.", + "Fetch the user's lab results over the whole record (or the user's lookback limit, when there is one): without arguments the latest reading per biomarker. Pass an analyte for how it changed over time: each matching marker then carries a history, the newest earlier values in full and older ones as one mean / min / max / count per year. Returns { present: false } with a reason — no_data means no labs are on file, outside_reach means they are older than the user's lookback limit.", parameters: { type: "object", additionalProperties: false, diff --git a/src/lib/ai/coach/tools/executor.ts b/src/lib/ai/coach/tools/executor.ts index 507d31b36..1f6b201b1 100644 --- a/src/lib/ai/coach/tools/executor.ts +++ b/src/lib/ai/coach/tools/executor.ts @@ -50,6 +50,7 @@ import { buildCoachSnapshot, type CoachSnapshotResult, } from "@/lib/ai/coach/snapshot"; +import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; import { buildCoachSourceSnapshot } from "@/lib/ai/coach/source-snapshot"; import { admitCoachSources, coachExclusions } from "@/lib/ai/coach/scope-gate"; import type { CoachPrefs } from "@/lib/validations/coach-prefs"; @@ -785,11 +786,11 @@ async function getLabs( // Labs ride the snapshot regardless of `sources` (attached unconditionally), // so a minimal scope still surfaces them. // - // v1.21.0 — the labs block itself is a "latest reading per biomarker - // over the last 12 months" snapshot and is intentionally window-AGNOSTIC - // (the read cutoff is fixed). We still thread the conversation's window onto - // the scope so the snapshot's `scope` block reports the right horizon for the - // turn; it does not move the labs read. + // v1.21.0 — the labs block is intentionally window-AGNOSTIC: the latest + // reading per biomarker with its summarised history, over the whole record + // or the user's lookback limit (#1263). We still thread the conversation's + // window onto the scope so the snapshot's `scope` block reports the right + // horizon for the turn; it does not move the labs read. // // v1.21.0 — prefer the turn's shared full-source scope so this read // lands the inventory's cache entry; fall back to a tight empty-source scope @@ -802,30 +803,28 @@ async function getLabs( const labs = pickSection(snapshot.sections, "labs") as { recent?: Array<{ name?: string; analyte?: string }> } | undefined; if (labs === undefined) { - // The labs read is window-AGNOSTIC: a fixed trailing-12-month cutoff. So the - // window it "searched" is a year regardless of the conversation's scope — - // a panel from 2023 is outside it and must not read as "no labs on file". - // Under a shorter lookback limit the labs read stops at the limit. + // The labs read is window-AGNOSTIC: it searched the whole record, or up to + // the lookback limit when there is one. A panel older than the limit must + // not read as "no labs on file", so the limit is the searched window. return emptyRead( userId, "labs", subjectForTool("get_labs"), - clampWindow("lastYear", reach), + reach.window, reach, ); } const analyte = parsed.data.analyte?.trim().toLowerCase(); - if (analyte && Array.isArray(labs.recent)) { - const filtered = labs.recent.filter((entry) => { - const haystack = - `${entry.name ?? ""} ${entry.analyte ?? ""}`.toLowerCase(); - return haystack.includes(analyte); + if (analyte) { + // #1263 — a named read carries each matching marker's history over the + // whole record (or the limit), summarised. Read on its own rather than + // out of the snapshot, whose labs block holds the latest values only. + const named = await buildLabsSnapshotBlock(userId, new Date(), reach, { + analyte, }); - if (filtered.length === 0) { - return { present: false, reason: "analyte_not_found" }; - } - return { present: true, data: { recent: filtered } }; + if (!named) return { present: false, reason: "analyte_not_found" }; + return { present: true, data: named }; } return { present: true, data: labs }; } diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index 8f3a8e3e9..483cbffa7 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -523,7 +523,7 @@ export const MCP_RESOURCE_TEMPLATES: McpResourceTemplateDefinition[] = [ uriTemplate: "healthlog://lab/{analyte}", title: "Lab analyte readings", description: - 'The user\'s recent readings for one named lab analyte (e.g. healthlog://lab/LDL) over the last 12 months — value, unit, reference range, and in-range/below/above status per reading. Same read get_labs serves. Returns { present: false } when no readings exist for that analyte. Returns { present: false, reason: "module_disabled" } when its module is switched off.', + 'The user\'s recent readings for one named lab analyte (e.g. healthlog://lab/LDL) over the whole record — the latest value, unit, reference range, and in-range/below/above status, with a summarised history of the earlier readings. Same read get_labs serves. Returns { present: false } when no readings exist for that analyte. Returns { present: false, reason: "module_disabled" } when its module is switched off.', mimeType: "application/json", complete: { analyte: completeAnalyte }, async list(ctx) { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 4eb2b3b17..2c9813c30 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -1677,7 +1677,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ name: "get_labs", title: "Get lab results", description: - "Fetch the user's lab results. By default returns the latest reading per biomarker over the last 12 months, optionally filtered to one named analyte. Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", inputShape: { analyte: z.string().min(1).max(80).optional(), history: z diff --git a/tests/integration/coach-labs-history.test.ts b/tests/integration/coach-labs-history.test.ts new file mode 100644 index 000000000..7273636d4 --- /dev/null +++ b/tests/integration/coach-labs-history.test.ts @@ -0,0 +1,144 @@ +/** + * The Coach reads lab results over the whole record, summarised (#1263). + * + * The labs block used to stop at twelve months whatever the lookback setting + * said, so a slow drift in LDL or PSA across years was invisible even with + * "All" chosen. Labs are sparse, so they now reach back over the whole record + * while the dense readings keep their one-year daily window; a long history + * folds into a bounded summary so the prompt stays small. A lookback limit the + * person set still applies. + * + * Real Postgres, real tool executor, a heavy fixture: thirty markers measured + * every quarter for twenty years. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { makeUser } from "../kit/factories"; +import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; +import { __resetCoachSnapshotCacheForTests } from "@/lib/ai/coach/snapshot"; +import { + HISTORY_REACH_DAYS, + UNBOUNDED_REACH, + type CoachHistoryReach, +} from "@/lib/ai/coach/history-reach"; +import type { CoachLabsBlock } from "@/lib/ai/coach/labs-snapshot"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +const prisma = getPrismaClient(); +const DAY = 86_400_000; +const MARKERS = 30; +const YEARS = 20; +const NOW = Date.now(); + +async function seedHeavyHistory(): Promise { + const user = await makeUser({ timezone: "UTC" }); + const rows = []; + for (let m = 0; m < MARKERS; m++) { + for (let q = 0; q < YEARS * 4; q++) { + rows.push({ + userId: user.id, + analyte: m === 0 ? "LDL" : `Marker ${m}`, + // LDL climbs one unit a quarter: 100 twenty years ago, 179 now. + value: m === 0 ? 179 - q : 50 + ((m * 7 + q) % 40), + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 130, + takenAt: new Date(NOW - (q * 91 + 2) * DAY), + }); + } + } + await prisma.labResult.createMany({ data: rows }); + return user.id; +} + +async function readLabs( + userId: string, + reach: CoachHistoryReach, + args: Record = {}, +): Promise { + const result = await executeCoachTool({ + userId, + name: "get_labs", + rawArguments: JSON.stringify(args), + fallbackWindow: "allTime", + reach, + }); + expect(result.present).toBe(true); + return (result as { data: CoachLabsBlock }).data; +} + +beforeEach(async () => { + await truncateAllTables(prisma); + __resetCoachSnapshotCacheForTests(); +}); +afterEach(() => { + __resetCoachSnapshotCacheForTests(); +}); + +describe("the Coach labs read over a twenty-year history", () => { + it("reads a marker last measured years ago, and keeps the overview small", async () => { + const userId = await seedHeavyHistory(); + // A panel from six years ago and nothing since: outside the old + // twelve-month read, inside the whole record. + await prisma.labResult.create({ + data: { + userId, + analyte: "PSA", + value: 1.4, + unit: "ng/mL", + takenAt: new Date(NOW - 6 * 365 * DAY), + }, + }); + const labs = await readLabs(userId, UNBOUNDED_REACH, { analyte: "psa" }); + expect(labs.recent.map((entry) => entry.value)).toEqual([1.4]); + + // The overview (and the snapshot block it is) carries the latest value + // per marker and no history: it shares the prompt's size cap. + const overview = await readLabs(userId, UNBOUNDED_REACH); + expect(overview.recent).toHaveLength(24); + expect(overview.recent.every((entry) => !entry.history)).toBe(true); + expect(JSON.stringify(overview).length).toBeLessThan(8_000); + }); + + it("summarises a named marker's whole history in a bounded entry", async () => { + const userId = await seedHeavyHistory(); + const labs = await readLabs(userId, UNBOUNDED_REACH, { analyte: "LDL" }); + + expect(labs.recent).toHaveLength(1); + const ldl = labs.recent[0]; + expect(ldl.value).toBe(179); + expect(ldl.history?.count).toBe(YEARS * 4); + const first = Date.parse(ldl.history!.firstTakenAt); + expect(NOW - first).toBeGreaterThan(19 * 365 * DAY); + // The drift is readable from the yearly means: lower long ago. + const yearly = ldl.history!.yearly; + expect(yearly.at(-1)!.mean).toBeLessThan(yearly[0].mean); + expect(yearly.length + ldl.history!.yearsOmitted).toBeGreaterThanOrEqual( + YEARS - 2, + ); + // Eighty readings, one bounded entry. + expect(JSON.stringify(labs).length).toBeLessThan(2_000); + }); + + it("stops at the lookback limit the person set", async () => { + const userId = await seedHeavyHistory(); + const labs = await readLabs( + userId, + { window: "lastYear", days: HISTORY_REACH_DAYS.lastYear }, + { analyte: "LDL" }, + ); + const ldl = labs.recent[0]; + expect(ldl.history!.count).toBeLessThanOrEqual(5); + expect(NOW - Date.parse(ldl.history!.firstTakenAt)).toBeLessThanOrEqual( + 365 * DAY, + ); + expect(ldl.history!.yearly).toEqual([]); + }); +});