feat(mcp): support arbitrary date ranges and per-analyte lab history

Add an optional explicit {from,to} range to the comparison and
changepoint reads, served over the same rollup tier the five fixed
trailing windows use (reach back to the lower bound, filter to the
span) — so a before-vs-after-a-date question is answerable without
recomputing analytics. Add a per-analyte lab trajectory read (latest to
recent history) over the existing lab resolver, page-bounded so a heavy
panel cannot balloon the response. Introduce an opaque, stable base64url
cursor helper that decodes any malformed or foreign token to the first
page rather than throwing.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 01:20:57 +02:00
1 parent 7e77a974d2
commit b40a963cb1
4 files changed
+514 -25

No files matched your search

+36
View File
@@ -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));
});
});
+118 -1
View File
@@ -15,12 +15,17 @@ vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
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");
});
});
+48
View File
@@ -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;
}
+312 -24
View File
@@ -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<CoachScopeWindow, number> = {
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<MetricWindowSnapshot | null> {
// 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<MetricWindowSnapshot | null> {
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<CompareMetricResult> {
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<ChangepointsResult> {
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<ReturnType<typeof readBestGranularityRollups>> = 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<LabHistoryResult> {
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) } : {}),
};
}