Files
HealthLog/tests/integration/coach-labs-history.test.ts
T
Marc-André Bombeck 190a83c513 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
2026-10-22 10:50:56 +02:00

145 lines
5.0 KiB
TypeScript

/**
* 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([]);
});
});