diff --git a/src/lib/mcp/__tests__/pagination.test.ts b/src/lib/mcp/__tests__/pagination.test.ts new file mode 100644 index 000000000..806216b3d --- /dev/null +++ b/src/lib/mcp/__tests__/pagination.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; + +import { encodeOffsetCursor, decodeOffsetCursor } from "../pagination"; + +describe("opaque cursor pagination", () => { + it("round-trips an offset through encode → decode", () => { + for (const offset of [0, 1, 8, 50, 200, 9999]) { + expect(decodeOffsetCursor(encodeOffsetCursor(offset))).toBe(offset); + } + }); + + it("encodes deterministically (stable token for the same offset)", () => { + expect(encodeOffsetCursor(8)).toBe(encodeOffsetCursor(8)); + }); + + it("emits an opaque token, not the raw number", () => { + const cursor = encodeOffsetCursor(8); + expect(cursor).not.toBe("8"); + expect(cursor).not.toContain("8"); + }); + + it("degrades a missing / malformed / foreign cursor to the first page", () => { + expect(decodeOffsetCursor(undefined)).toBe(0); + expect(decodeOffsetCursor("")).toBe(0); + expect(decodeOffsetCursor("not-base64!@#")).toBe(0); + expect( + decodeOffsetCursor(Buffer.from("[1,2,3]").toString("base64url")), + ).toBe(0); + expect(decodeOffsetCursor(42 as unknown)).toBe(0); + }); + + it("clamps a negative or non-finite offset to zero", () => { + expect(encodeOffsetCursor(-5)).toBe(encodeOffsetCursor(0)); + expect(encodeOffsetCursor(Number.NaN)).toBe(encodeOffsetCursor(0)); + }); +}); diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 077449238..ab61d1ff2 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -15,12 +15,17 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => { await importOriginal(); return { ...actual, readBestGranularityRollups: vi.fn() }; }); +const { labResult } = vi.hoisted(() => ({ + labResult: { findMany: vi.fn() }, +})); +vi.mock("@/lib/db", () => ({ prisma: { labResult } })); import { getCorrelation, compareMetric, getMetricBaseline, detectChangepoints, + getLabHistory, resolveRichMetric, } from "../rich-reads"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; @@ -198,7 +203,7 @@ describe("compare_metric", () => { it("asks for a second metric or window when neither is given", async () => { const res = await compareMetric(USER, { metric: "weight" }); expect(res.present).toBe(false); - expect(res.reason).toBe("specify_metricB_or_windowB"); + expect(res.reason).toBe("specify_metricB_window_or_range"); }); it("honest-null on unknown metric", async () => { @@ -364,3 +369,115 @@ describe("grounding / no fabrication", () => { } }); }); + +// ── explicit {from,to} date range ──────────────────────────────────── +describe("explicit date-range reads", () => { + /** A range entirely within the rows the reader will return. */ + const fromMs = Date.now() - 40 * 24 * 60 * 60 * 1000; + const toMs = Date.now() - 10 * 24 * 60 * 60 * 1000; + const inRange = new Date(Date.now() - 25 * 24 * 60 * 60 * 1000).toISOString(); + const beforeRange = new Date( + Date.now() - 80 * 24 * 60 * 60 * 1000, + ).toISOString(); + const from = new Date(fromMs).toISOString(); + const to = new Date(toMs).toISOString(); + + it("compare_metric serves a before-vs-after range comparison", async () => { + // Side A (range) and side B (rangeB) each read once; rows outside the + // bounds are filtered out before aggregation. + vi.mocked(readBestGranularityRollups) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row(inRange, 80), row(beforeRange, 200)], + }) + .mockResolvedValueOnce({ + granularity: "DAY", + rows: [row(inRange, 90)], + }); + const res = await compareMetric(USER, { + metric: "weight", + range: { from, to }, + rangeB: { from, to }, + }); + expect(res.present).toBe(true); + expect(res.mode).toBe("window_vs_window"); + // The out-of-range 200 row was filtered → side A mean is 80, not 140. + expect(res.a?.mean).toBe(80); + expect(res.a?.from).toBe(from); + expect(res.a?.to).toBe(to); + expect(res.delta?.mean).toBe(10); + }); + + it("detect_changepoints accepts an explicit range and only scans in-range buckets", async () => { + const rows = []; + for (let i = 0; i < 12; i++) { + const d = new Date( + Date.now() - (35 - i) * 24 * 60 * 60 * 1000, + ).toISOString(); + rows.push(row(d, i < 6 ? 60 : 90)); + } + // Add an out-of-range early bucket that must be excluded. + rows.unshift(row(beforeRange, 5)); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + const res = await detectChangepoints(USER, { + metric: "weight", + range: { from, to }, + }); + expect(res.present).toBe(true); + expect(res.bucketsAnalysed).toBe(12); // the out-of-range bucket excluded + expect(res.changepoints?.length ?? 0).toBeGreaterThan(0); + }); +}); + +// ── 5. get_lab_history ─────────────────────────────────────────────── +describe("get_lab_history", () => { + function labRow(takenAt: string, value: number) { + return { + analyte: "LDL", + panel: "Lipids", + value, + valueText: null, + unit: "mg/dL", + referenceLow: null, + referenceHigh: 116, + takenAt: new Date(takenAt), + biomarkerId: null, + biomarker: null, + }; + } + + it("returns a newest-first trajectory with units + range status", async () => { + labResult.findMany.mockResolvedValue([ + labRow("2026-06-01T00:00:00Z", 130), + labRow("2026-03-01T00:00:00Z", 110), + ]); + const res = await getLabHistory(USER, { analyte: "LDL", limit: 50 }); + expect(res.present).toBe(true); + expect(res.analyte).toBe("LDL"); + expect(res.readings).toHaveLength(2); + expect(res.readings?.[0].value).toBe(130); + expect(res.readings?.[0].rangeStatus).toBe("above"); + expect(res.nextCursor).toBeUndefined(); + }); + + it("paginates with an opaque cursor when more readings exist", async () => { + // Ask for limit 1: the reader peeks limit+1 rows to detect more. + labResult.findMany.mockResolvedValue([ + labRow("2026-06-01T00:00:00Z", 130), + labRow("2026-03-01T00:00:00Z", 110), + ]); + const res = await getLabHistory(USER, { analyte: "LDL", limit: 1 }); + expect(res.readings).toHaveLength(1); + expect(typeof res.nextCursor).toBe("string"); + }); + + it("honest-null when no reading matches the analyte", async () => { + labResult.findMany.mockResolvedValue([]); + const res = await getLabHistory(USER, { analyte: "ferritin" }); + expect(res.present).toBe(false); + expect(res.reason).toBe("analyte_not_found"); + }); +}); diff --git a/src/lib/mcp/pagination.ts b/src/lib/mcp/pagination.ts new file mode 100644 index 000000000..a4f6fc68d --- /dev/null +++ b/src/lib/mcp/pagination.ts @@ -0,0 +1,48 @@ +/** + * Opaque cursor pagination for the MCP reads. + * + * The MCP spec models pagination as an OPAQUE, server-chosen cursor token: the + * client must treat it as a black box, never parse or synthesise it, and pass it + * back verbatim to fetch the next page. These helpers encode a simple offset into + * a base64url token so a client cannot construct a cursor by hand or smuggle a + * different shape through it. The encoding is stable — the same offset always + * yields the same token — and decoding is fail-safe: any malformed, foreign, or + * absent cursor decodes to offset 0 (the first page) rather than throwing, so a + * bad cursor degrades to "start over" instead of breaking the read. + * + * The cursor carries ONLY a non-negative integer offset. It is not signed and + * not user-scoped because it leaks nothing: the offset is meaningless without the + * session-narrowed query it pages, and every read already feeds `ctx.userId` into + * the Prisma `where` — a replayed cursor can only re-page the SAME caller's data. + */ + +/** Encode a non-negative offset into an opaque base64url cursor token. */ +export function encodeOffsetCursor(offset: number): string { + const safe = Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0; + return Buffer.from(JSON.stringify({ o: safe }), "utf8").toString("base64url"); +} + +/** + * Decode an opaque cursor back to its offset. Returns 0 for a missing, + * malformed, or foreign cursor so a bad token degrades to the first page rather + * than throwing. + */ +export function decodeOffsetCursor(cursor: unknown): number { + if (typeof cursor !== "string" || cursor.length === 0) return 0; + try { + const json = Buffer.from(cursor, "base64url").toString("utf8"); + const parsed = JSON.parse(json) as unknown; + if ( + parsed && + typeof parsed === "object" && + "o" in parsed && + typeof (parsed as { o: unknown }).o === "number" + ) { + const o = (parsed as { o: number }).o; + return Number.isFinite(o) && o > 0 ? Math.floor(o) : 0; + } + } catch { + // Fall through to the safe default. + } + return 0; +} diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 0da7d7c66..ddb60504f 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -30,19 +30,60 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read"; import { readBestGranularityRollups, aggregateWmyBuckets, + type RollupBucketRow, } from "@/lib/rollups/measurement-read-wmy"; import { getMetricStatusMeta, METRIC_STATUS_IDS, } from "@/lib/insights/metric-status-registry"; +import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { resolveLabFields } from "@/lib/labs/serialise"; +import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot"; +import { encodeOffsetCursor } from "@/lib/mcp/pagination"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * An explicit, arbitrary ISO date range the rich reads can take in place of one + * of the five fixed trailing windows. Bounds are inclusive. The range is served + * over the SAME rollup tier the trailing windows use: the reader fetches the + * trailing buckets reaching back to `from` (the largest granularity that still + * resolves that span) and the rich read filters them to `[from, to]` — no new + * persisted analytics, no recompute. A coarse granularity (WEEK / MONTH / + * YEAR) is selected automatically for a range that reaches far into the past, + * and the resolved granularity is reported on the result. + */ +export interface DateRange { + /** Inclusive lower bound (ISO-8601 instant). */ + from: string; + /** Inclusive upper bound (ISO-8601 instant). */ + to: string; +} + +/** + * Validate + normalise an explicit `{from,to}` range to millisecond bounds. + * Returns `null` for a missing, unparseable, or inverted range so the caller + * falls back to the trailing-window path rather than reading a nonsense span. + */ +function resolveRangeMs( + range: DateRange | undefined, +): { fromMs: number; toMs: number } | null { + if (!range) return null; + const fromMs = Date.parse(range.from); + const toMs = Date.parse(range.to); + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs)) return null; + if (toMs <= fromMs) return null; + return { fromMs, toMs }; +} + /** Trailing-day count for each window the rich reads accept. */ const WINDOW_DAYS: Record = { last7days: 7, @@ -297,6 +338,10 @@ interface MetricWindowSnapshot { mean: number | null; min: number | null; max: number | null; + /** Lower bound when the side was an explicit `{from,to}` range. */ + from?: string; + /** Upper bound when the side was an explicit `{from,to}` range. */ + to?: string; } export interface CompareMetricResult { @@ -309,6 +354,12 @@ export interface CompareMetricResult { delta?: { mean: number; pct: number | null } | null; } +/** One side's horizon: an explicit range wins over a trailing window. */ +interface SideSpec { + window?: CoachScopeWindow; + range?: DateRange; +} + async function snapshotMetricWindow( userId: string, metric: RichMetric, @@ -336,12 +387,69 @@ async function snapshotMetricWindow( } /** - * Compare a metric against another metric (same trailing window) OR a single - * metric across two trailing windows. Pure re-export of the WMY rollup reader + - * the linear `aggregateWmyBuckets` composition — no new math. Windows are - * trailing-to-now (e.g. last 30 days vs last 90 days); a delta is only computed - * when both sides carry the same unit, so the result never compares unlike - * scales. `{ present: false }` when neither side has data. + * Snapshot a metric over an explicit `{from,to}` range. Reuses the SAME rollup + * reader the trailing-window path uses: it fetches the trailing buckets reaching + * back to `from` (so the largest granularity that still resolves that reach is + * picked) and filters them to `[from, to]`. No new persisted analytics. + */ +async function snapshotMetricRange( + userId: string, + metric: RichMetric, + fromMs: number, + toMs: number, +): Promise { + // Reach back far enough to cover `from`; the upper bound is applied below. + const reachDays = Math.ceil((Date.now() - fromMs) / DAY_MS); + if (!Number.isFinite(reachDays) || reachDays <= 0) return null; + const read = await readBestGranularityRollups( + userId, + metric.measurementType, + reachDays, + ); + if (!read) return null; + const rows = read.rows.filter((r) => { + const t = r.bucketStart.getTime(); + return t >= fromMs && t <= toMs; + }); + if (rows.length === 0) return null; + const agg = aggregateWmyBuckets(rows); + if (agg.count === 0) return null; + return { + label: metric.label, + unit: metric.unit, + band: metric.band, + windowDays: Math.max(1, Math.round((toMs - fromMs) / DAY_MS)), + granularity: read.granularity, + count: agg.count, + mean: agg.mean, + min: agg.min, + max: agg.max, + from: new Date(fromMs).toISOString(), + to: new Date(toMs).toISOString(), + }; +} + +/** Snapshot one comparison side — an explicit range wins over a window. */ +function snapshotSide( + userId: string, + metric: RichMetric, + spec: SideSpec, +): Promise { + const range = resolveRangeMs(spec.range); + if (range) { + return snapshotMetricRange(userId, metric, range.fromMs, range.toMs); + } + return snapshotMetricWindow(userId, metric, windowToDays(spec.window)); +} + +/** + * Compare a metric against another metric (same horizon) OR a single metric + * across two horizons. Each horizon is either one of the five fixed trailing + * windows OR an explicit `{from,to}` range (e.g. before vs after a date). Pure + * re-export of the WMY rollup reader + the linear `aggregateWmyBuckets` + * composition — no new math. A delta is only computed when both sides carry the + * same unit, so the result never compares unlike scales. `{ present: false }` + * when neither side has data. */ export async function compareMetric( userId: string, @@ -350,13 +458,15 @@ export async function compareMetric( metricB?: string; window?: CoachScopeWindow; windowB?: CoachScopeWindow; + range?: DateRange; + rangeB?: DateRange; }, ): Promise { const metricA = resolveRichMetric(args.metric); if (!metricA) { return { present: false, reason: "unknown_metric" }; } - const daysA = windowToDays(args.window); + const sideA: SideSpec = { window: args.window, range: args.range }; const annotateMiss = () => annotate({ @@ -366,7 +476,7 @@ export async function compareMetric( let mode: "metric_vs_metric" | "window_vs_window"; let metricB: RichMetric; - let daysB: number; + let sideB: SideSpec; if (args.metricB) { const resolvedB = resolveRichMetric(args.metricB); @@ -374,21 +484,22 @@ export async function compareMetric( annotateMiss(); return { present: false, reason: "unknown_metric_b" }; } + // Both metrics share side A's horizon (window or range). mode = "metric_vs_metric"; metricB = resolvedB; - daysB = daysA; - } else if (args.windowB) { + sideB = sideA; + } else if (args.rangeB || args.windowB) { mode = "window_vs_window"; metricB = metricA; - daysB = windowToDays(args.windowB); + sideB = { window: args.windowB, range: args.rangeB }; } else { annotateMiss(); - return { present: false, reason: "specify_metricB_or_windowB" }; + return { present: false, reason: "specify_metricB_window_or_range" }; } const [a, b] = await Promise.all([ - snapshotMetricWindow(userId, metricA, daysA), - snapshotMetricWindow(userId, metricB, daysB), + snapshotSide(userId, metricA, sideA), + snapshotSide(userId, metricB, sideB), ]); if (!a || !b) { @@ -591,19 +702,48 @@ function cusumSegment( */ export async function detectChangepoints( userId: string, - args: { metric: string; window?: CoachScopeWindow }, + args: { metric: string; window?: CoachScopeWindow; range?: DateRange }, ): Promise { const metric = resolveRichMetric(args.metric); if (!metric) { return { present: false, reason: "unknown_metric" }; } - const windowDays = windowToDays(args.window ?? "last90days"); - const read = await readBestGranularityRollups( - userId, - metric.measurementType, - windowDays, - ); + // Either an explicit `{from,to}` range (reach-back + filter) or one of the + // five fixed trailing windows — both land on the same rollup reader. + const rangeMs = resolveRangeMs(args.range); + let windowDays: number; + let rangeRows: RollupBucketRow[] | null = null; + let read: Awaited> = null; + + if (rangeMs) { + windowDays = Math.max( + 1, + Math.round((rangeMs.toMs - rangeMs.fromMs) / DAY_MS), + ); + const reachDays = Math.ceil((Date.now() - rangeMs.fromMs) / DAY_MS); + read = + Number.isFinite(reachDays) && reachDays > 0 + ? await readBestGranularityRollups( + userId, + metric.measurementType, + reachDays, + ) + : null; + rangeRows = + read?.rows.filter((r) => { + const t = r.bucketStart.getTime(); + return t >= rangeMs.fromMs && t <= rangeMs.toMs; + }) ?? null; + } else { + windowDays = windowToDays(args.window ?? "last90days"); + read = await readBestGranularityRollups( + userId, + metric.measurementType, + windowDays, + ); + rangeRows = read?.rows ?? null; + } const miss = (reason: string): ChangepointsResult => { annotate({ @@ -619,11 +759,12 @@ export async function detectChangepoints( }; }; - if (!read || read.rows.length < 2 * MIN_SEGMENT) { + if (!read || !rangeRows || rangeRows.length < 2 * MIN_SEGMENT) { return miss("insufficient_data"); } - const values = read.rows.map((r) => r.mean); + const rows = rangeRows; + const values = rows.map((r) => r.mean); const found: Array<{ index: number; beforeMean: number; afterMean: number }> = []; cusumSegment(values, 0, values.length, found); @@ -644,7 +785,7 @@ export async function detectChangepoints( ) .slice(0, MAX_CHANGEPOINTS) .map((cp) => ({ - at: read.rows[cp.index].bucketStart.toISOString(), + at: rows[cp.index].bucketStart.toISOString(), direction: cp.afterMean >= cp.beforeMean ? ("increase" as const) @@ -669,3 +810,150 @@ export async function detectChangepoints( changepoints, }; } + +// ── 5. get_lab_history (per-analyte trajectory) ────────────────────── + +/** One reading on a single analyte's trajectory. */ +export interface LabHistoryReading { + /** Numeric reading; null for a qualitative row (see `valueText`). */ + value: number | null; + /** Qualitative result text ("negativ" / …); null for a numeric row. */ + valueText: string | null; + unit: string; + referenceLow: number | null; + referenceHigh: number | null; + /** in-range / below / above / unknown — computed from the resolved bounds. */ + rangeStatus: "in-range" | "below" | "above" | "unknown"; + /** Measured date (ISO). */ + takenAt: string; +} + +export interface LabHistoryResult { + present: boolean; + reason?: string; + /** The canonical analyte the trajectory resolved to. */ + analyte?: string; + /** Newest-first readings for this analyte (the requested page). */ + readings?: LabHistoryReading[]; + /** Opaque cursor for the next page; absent when the last page was returned. */ + nextCursor?: string; +} + +/** Hard ceiling per page so a heavy panel history never balloons the wire. */ +export const LAB_HISTORY_MAX_LIMIT = 50; + +/** + * Return ONE analyte's reading trajectory (newest first) over the user's own + * lab record. Re-uses the SAME resolver + range classifier the latest-only labs + * snapshot and the Labs API use (`resolveLabFields` / `classifyReferenceRange`) + * — no new analytics, no fabricated value. DB-paginated by `offset` so a long + * history stays token-bounded: the page carries at most `limit` readings and a + * `nextCursor` when more exist. The encrypted `noteEncrypted` column is never + * selected, so a decrypted note can never reach the wire. `{ present: false }` + * when no reading matches the analyte. + */ +export async function getLabHistory( + userId: string, + args: { analyte: string; offset?: number; limit?: number }, +): Promise { + const needle = args.analyte.trim(); + if (!needle) { + return { present: false, reason: "analyte_required" }; + } + const offset = + typeof args.offset === "number" && + Number.isFinite(args.offset) && + args.offset > 0 + ? Math.floor(args.offset) + : 0; + const rawLimit = + typeof args.limit === "number" && + Number.isFinite(args.limit) && + args.limit > 0 + ? Math.floor(args.limit) + : LAB_HISTORY_MAX_LIMIT; + const limit = Math.min(LAB_HISTORY_MAX_LIMIT, rawLimit); + + // Match the analyte either on the LabResult's own `analyte` column or on the + // linked Biomarker's canonical name. `take: limit + 1` peeks one row past the + // page to decide whether a `nextCursor` is warranted. Field-by-field `where`. + const rows = await prisma.labResult.findMany({ + where: { + userId, + deletedAt: null, + OR: [ + { analyte: { contains: needle, mode: "insensitive" } }, + { biomarker: { name: { contains: needle, mode: "insensitive" } } }, + ], + }, + orderBy: { takenAt: "desc" }, + skip: offset, + take: limit + 1, + select: { + analyte: true, + panel: true, + value: true, + valueText: true, + unit: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + biomarkerId: true, + biomarker: { + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + panel: true, + }, + }, + }, + }); + + if (rows.length === 0) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_labs", present: false }, + }); + return { present: false, reason: "analyte_not_found" }; + } + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + const readings: LabHistoryReading[] = page.map((row) => { + const resolved = resolveLabFields(row, row.biomarker); + return { + value: row.value, + valueText: row.valueText ? sanitizeValueText(row.valueText) : null, + unit: resolved.unit, + referenceLow: resolved.referenceLow, + referenceHigh: resolved.referenceHigh, + rangeStatus: + row.value === null + ? "unknown" + : classifyReferenceRange( + row.value, + resolved.referenceLow, + resolved.referenceHigh, + ), + takenAt: row.takenAt.toISOString(), + }; + }); + + // Canonical analyte for the trajectory — take the first (newest) resolved row. + const canonical = resolveLabFields(page[0], page[0].biomarker).analyte; + + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_labs", present: true }, + }); + return { + present: true, + analyte: canonical, + readings, + ...(hasMore ? { nextCursor: encodeOffsetCursor(offset + limit) } : {}), + }; +}