mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
6a2247e422
commit
190a83c513
21 files changed
+586
-119
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user