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
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 10:50:56 +02:00
1 parent 6a2247e422
commit 190a83c513
21 files changed
+586 -119

No files matched your search

+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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": "대화 보기",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
@@ -151,7 +151,7 @@ describe("<SourcesRail>", () => {
// `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");
});
@@ -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 },
});
});
});
@@ -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 },
]);
});
});
@@ -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();
});
@@ -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",
+9 -7
View File
@@ -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,
},
};
}
+207 -54
View File
@@ -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<CoachLabReading & { history?: CoachLabHistory }>;
}
/** 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<number, number[]>();
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<CoachLabsBlock | null> {
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<string>();
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;
+20 -8
View File
@@ -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
+2 -2
View File
@@ -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,
+18 -19
View File
@@ -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 };
}
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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
@@ -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<string> {
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<string, unknown> = {},
): Promise<CoachLabsBlock> {
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([]);
});
});