From ea7c6c860e5d346aed3a15e9f8fa5feedc714dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:24:27 +0100 Subject: [PATCH 1/3] feat(mcp): share one record-link builder and list labs and medications on a document The links fetch lists beside a visit, condition, document or vaccination move out of record-search into record-links, so the lab and medication reads can list theirs through the same builder. A linked document imported from Paperless-ngx or Papra now names its source system and its id there, never the instance address. fetch document: also lists the lab results read from it and the medications filed against it, each behind its own module. --- .../baselines/source-file-lines.json | 4 +- src/lib/mcp/record-links.ts | 258 ++++++++++++++++++ src/lib/mcp/record-search.ts | 126 ++------- 3 files changed, 288 insertions(+), 100 deletions(-) create mode 100644 src/lib/mcp/record-links.ts diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 27b6e688a..79303990c 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 84, - "total": 94362, + "total": 94292, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -69,7 +69,7 @@ "lib/jobs/offhost-backup.ts": 1094, "lib/jobs/reaction-line.ts": 803, "lib/managed-profiles/handover.ts": 986, - "lib/mcp/record-search.ts": 1467, + "lib/mcp/record-search.ts": 1397, "lib/mcp/rich-reads.ts": 1467, "lib/measurements/dense-intraday-retention.ts": 1016, "lib/measurements/import-apple-health-export.ts": 1722, diff --git a/src/lib/mcp/record-links.ts b/src/lib/mcp/record-links.ts new file mode 100644 index 000000000..d06542b49 --- /dev/null +++ b/src/lib/mcp/record-links.ts @@ -0,0 +1,258 @@ +/** + * The one-hop links an MCP read lists beside a record: `fetch` on a visit, + * condition, document, vaccination, lab or medication, and the lab and + * medication list reads. One builder, so every read names a linked record the + * same way: an id `fetch` accepts, a label, a date, and for an imported + * document the system it came from and its id there. + * + * Reads go through the link service only (`@/lib/links`), which narrows the + * link rows AND the targets to the caller's account, so another account's + * record cannot appear here. A target kind whose module is switched off is not + * read at all, the way `fetch` has always treated a switched-off kind. + * + * Labels are the person's own words (a document title, an analyte, a + * medication name): scrubbed of forged fence markers in structured fields, and + * fenced in the prose sentence. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; +import { encounterKindLabel } from "@/lib/encounters/kind-label"; +import { listTargetsBySource } from "@/lib/links"; +import type { + LinkSourceKind, + LinkTargetKind, + LinkedTarget, +} from "@/lib/links/link-service"; +import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; +import { + toDocumentSourceSystem, + type DocumentSourceSystemValue, +} from "@/lib/validations/inbound-documents"; + +/** One-hop links one record lists. */ +export const MAX_LINKS = 25; + +/** What a medication lists as linked, in this order. */ +export const MEDICATION_LINK_TARGETS = [ + "conditionEpisode", + "document", + "encounter", +] as const; + +export type RecordLinkKind = + "visit" | "condition" | "document" | "vaccination" | "lab" | "medication"; + +export interface RecordLink { + id: string | null; + kind: RecordLinkKind; + label: string; + date: string | null; + /** + * For a document imported from Paperless-ngx or Papra: the system and the + * document's id there. Never the instance address. + */ + sourceSystem?: DocumentSourceSystemValue; + sourceId?: string; +} + +/** The module each link target belongs to; a visit belongs to none. */ +const TARGET_MODULE: Record = { + encounter: null, + document: "inboundDocuments", + conditionEpisode: "illness", + labResult: "labs", + medication: "medications", + vaccination: "vaccinations", +}; + +/** The link kind and the `fetch` id prefix of each target kind. */ +const TARGET_LINK: Record< + Exclude, + { kind: RecordLinkKind; prefix: string } +> = { + encounter: { kind: "visit", prefix: "visit" }, + document: { kind: "document", prefix: "document" }, + conditionEpisode: { kind: "condition", prefix: "condition" }, + medication: { kind: "medication", prefix: "med" }, + vaccination: { kind: "vaccination", prefix: "vaccination" }, +}; + +function linkDate(iso: string | null): string | null { + return iso ? iso.slice(0, 10) : null; +} + +/** A linked visit is labelled by its kind, in English on this wire. */ +function visitLinkLabel(target: LinkedTarget): string { + return encounterKindLabel(target.label as EncounterKind, "en"); +} + +/** A lab result is fetched by its analyte, so the link id names that. */ +async function labLinks( + userId: string, + targets: readonly LinkedTarget[], +): Promise> { + // Asked here as well as by every caller: an analyte name is lab data. + if (!(await isModuleEnabled(userId, "labs"))) return new Map(); + const rows = await prisma.labResult.findMany({ + where: { userId, id: { in: targets.map((t) => t.id) }, deletedAt: null }, + select: { id: true, analyte: true }, + }); + const analyte = new Map(rows.map((r) => [r.id, r.analyte])); + const out = new Map(); + for (const target of targets) { + const name = analyte.get(target.id); + if (!name) continue; + out.set(target.id, { + id: `lab:${name}`, + kind: "lab", + label: scrubFenceMarkers(target.label), + date: linkDate(target.date), + }); + } + return out; +} + +/** An imported document says where it came from: system and id, no address. */ +async function documentSources( + userId: string, + targets: readonly LinkedTarget[], +): Promise>> { + const rows = await prisma.inboundDocument.findMany({ + where: { userId, id: { in: targets.map((t) => t.id) } }, + select: { id: true, sourceSystem: true, sourceId: true }, + }); + const out = new Map>(); + for (const row of rows) { + const system = toDocumentSourceSystem(row.sourceSystem); + if (system && row.sourceId) { + out.set(row.id, { + sourceSystem: system, + sourceId: scrubFenceMarkers(row.sourceId), + }); + } + } + return out; +} + +/** Resolve one target kind's targets into links, keyed by target id. */ +async function resolveLinks( + userId: string, + targetKind: LinkTargetKind, + targets: readonly LinkedTarget[], +): Promise> { + if (targets.length === 0) return new Map(); + if (targetKind === "labResult") return labLinks(userId, targets); + const { kind, prefix } = TARGET_LINK[targetKind]; + const sources = + targetKind === "document" + ? await documentSources(userId, targets) + : new Map>(); + return new Map( + targets.map((target) => [ + target.id, + { + id: `${prefix}:${target.id}`, + kind, + label: scrubFenceMarkers( + targetKind === "encounter" ? visitLinkLabel(target) : target.label, + ), + date: linkDate(target.date), + ...sources.get(target.id), + }, + ]), + ); +} + +/** Links for targets already read by the caller, in their order. */ +export async function linksFor( + userId: string, + targetKind: LinkTargetKind, + targets: readonly LinkedTarget[], +): Promise { + const resolved = await resolveLinks(userId, targetKind, targets); + return targets.flatMap((t) => resolved.get(t.id) ?? []); +} + +/** The target kinds whose module is on for this account, in order. */ +export async function visibleTargetKinds( + userId: string, + kinds: readonly LinkTargetKind[], +): Promise { + const on = await Promise.all( + kinds.map((kind) => { + const owner = TARGET_MODULE[kind]; + return owner ? isModuleEnabled(userId, owner) : true; + }), + ); + return kinds.filter((_, i) => on[i]); +} + +/** + * The links of many sources at once, keyed by source id, unbounded (bound + * them per record with {@link boundLinks}). One link read and one target read + * per target kind for the whole set, so a list of readings costs the same few + * round trips as one. A source with no link maps to an empty list. + */ +export async function linksBySource( + userId: string, + sourceKind: LinkSourceKind, + sourceIds: readonly string[], + targetKinds: readonly LinkTargetKind[], +): Promise> { + const out = new Map(sourceIds.map((id) => [id, []])); + if (sourceIds.length === 0) return out; + const visible = await visibleTargetKinds(userId, targetKinds); + const perKind = await Promise.all( + visible.map(async (targetKind) => { + const bySource = await listTargetsBySource(prisma, { + userId, + sourceKind, + sourceIds: [...sourceIds], + targetKind, + }); + const resolved = await resolveLinks( + userId, + targetKind, + [...bySource.values()].flat(), + ); + return { bySource, resolved }; + }), + ); + for (const { bySource, resolved } of perKind) { + for (const [sourceId, targets] of bySource) { + const list = out.get(sourceId); + if (!list) continue; + list.push(...targets.flatMap((t) => resolved.get(t.id) ?? [])); + } + } + return out; +} + +/** The prose form of a record's links, with the same empty answer everywhere. */ +export function linksSentence(links: readonly RecordLink[]): string { + if (links.length === 0) return "Linked records: none."; + return `Linked records: ${links + .map((l) => { + const detail = [ + l.date, + l.sourceSystem && l.sourceId + ? `${l.sourceSystem} id ${fenceUserText(l.sourceId)}` + : null, + ].filter((d): d is string => d !== null); + return `${l.kind} ${fenceUserText(l.label)}${ + detail.length > 0 ? ` (${detail.join(", ")})` : "" + }`; + }) + .join("; ")}.`; +} + +export function boundLinks(links: readonly RecordLink[]): { + links: RecordLink[]; + linksTruncated: boolean; +} { + return { + links: links.slice(0, MAX_LINKS), + linksTruncated: links.length > MAX_LINKS, + }; +} diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 34aafb724..cb4b23303 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -58,7 +58,13 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { listTargets } from "@/lib/links"; -import type { LinkedTarget } from "@/lib/links/link-service"; +import { + boundLinks, + linksBySource, + linksFor, + linksSentence, + type RecordLink, +} from "./record-links"; import { isModuleEnabled, type ModuleKey } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; import { @@ -83,8 +89,6 @@ export const MAX_ROWS_PER_KIND = 500; export const EMPTY_QUERY_PER_KIND = 10; /** Characters of indexed document text one `fetch` returns. */ export const MAX_DOCUMENT_EXCERPT_CHARS = 1500; -/** One-hop links one `fetch` returns. */ -export const MAX_LINKS = 25; // ── Kinds and their modules ────────────────────────────────────────── @@ -923,13 +927,6 @@ export async function loadRecordCandidates( // ── Fetch ──────────────────────────────────────────────────────────── -export interface RecordLink { - id: string | null; - kind: RecordKind | "lab"; - label: string; - date: string | null; -} - export interface FetchResult { id: string; title: string; @@ -938,72 +935,6 @@ export interface FetchResult { metadata: Record; } -function linkDate(iso: string | null): string | null { - return iso ? iso.slice(0, 10) : null; -} - -function toLinks( - kind: RecordKind, - targets: readonly LinkedTarget[], - label: (target: LinkedTarget) => string = (t) => t.label, -): RecordLink[] { - return targets.map((target) => ({ - id: `${kind}:${target.id}`, - kind, - label: scrubFenceMarkers(label(target)), - date: linkDate(target.date), - })); -} - -/** A linked visit is labelled by its kind, in English on this wire. */ -function visitLinkLabel(target: LinkedTarget): string { - return encounterKindLabel(target.label as EncounterKind, "en"); -} - -async function labLinks( - userId: string, - targets: readonly LinkedTarget[], -): Promise { - if (targets.length === 0) return []; - const rows = await prisma.labResult.findMany({ - where: { userId, id: { in: targets.map((t) => t.id) }, deletedAt: null }, - select: { id: true, analyte: true }, - }); - const analyte = new Map(rows.map((r) => [r.id, r.analyte])); - return targets.flatMap((target) => { - const name = analyte.get(target.id); - if (!name) return []; - return [ - { - id: `lab:${name}`, - kind: "lab" as const, - label: scrubFenceMarkers(target.label), - date: linkDate(target.date), - }, - ]; - }); -} - -function linksSentence(links: readonly RecordLink[]): string { - if (links.length === 0) return "Linked records: none."; - return `Linked records: ${links - .map( - (l) => - `${l.kind} ${fenceUserText(l.label)}${l.date ? ` (${l.date})` : ""}`, - ) - .join("; ")}.`; -} - -function boundLinks(links: RecordLink[]): { - links: RecordLink[]; - linksTruncated: boolean; -} { - return { - links: links.slice(0, MAX_LINKS), - linksTruncated: links.length > MAX_LINKS, - }; -} - function moduleOff( id: string, kind: RecordKind, @@ -1075,9 +1006,9 @@ async function fetchVisit( : Promise.resolve([]), ]); const all: RecordLink[] = [ - ...toLinks("condition", conditions), - ...toLinks("document", documents), - ...(await labLinks(userId, labs)), + ...(await linksFor(userId, "conditionEpisode", conditions)), + ...(await linksFor(userId, "document", documents)), + ...(await linksFor(userId, "labResult", labs)), ...doses.map((dose) => ({ id: `vaccination:${dose.id}`, kind: "vaccination" as const, @@ -1171,8 +1102,8 @@ async function fetchCondition( : Promise.resolve([]), ]); const { links, linksTruncated } = boundLinks([ - ...toLinks("visit", visits, visitLinkLabel), - ...toLinks("document", documents), + ...(await linksFor(userId, "encounter", visits)), + ...(await linksFor(userId, "document", documents)), ]); const bodySite = decryptText(row.bodySiteEncrypted); @@ -1246,12 +1177,20 @@ async function documentExcerpt( } } +/** What a document lists as linked, in this order. */ +const DOCUMENT_LINK_TARGETS = [ + "encounter", + "conditionEpisode", + "vaccination", + "labResult", + "medication", +] as const; + async function fetchDocument( userId: string, id: string, rid: string, origin: string, - kinds: ReadonlySet, ): Promise { // Metadata only. The stored bytes and the model-written summary are never // selected, so neither can reach this answer by accident. @@ -1270,24 +1209,15 @@ async function fetchDocument( // and still are. const deferred = row.aiReadDeferred; - const source = { userId, sourceKind: "document" as const, sourceId: rid }; - const [content, visits, conditions, doses] = await Promise.all([ + // The lab results read from this document and the medications filed + // against it are listed too, each behind its own module like the rest. + const [content, linked] = await Promise.all([ deferred ? Promise.resolve({ indexed: false, excerpt: null }) : documentExcerpt(userId, rid), - listTargets(prisma, { ...source, targetKind: "encounter" }), - kinds.has("condition") - ? listTargets(prisma, { ...source, targetKind: "conditionEpisode" }) - : Promise.resolve([]), - kinds.has("vaccination") - ? listTargets(prisma, { ...source, targetKind: "vaccination" }) - : Promise.resolve([]), - ]); - const { links, linksTruncated } = boundLinks([ - ...toLinks("visit", visits, visitLinkLabel), - ...toLinks("condition", conditions), - ...toLinks("vaccination", doses), + linksBySource(userId, "document", [rid], DOCUMENT_LINK_TARGETS), ]); + const { links, linksTruncated } = boundLinks(linked.get(rid) ?? []); const kindLabel = documentKindLabel(row.kind, "en"); const name = row.title ?? row.filename ?? kindLabel; @@ -1371,7 +1301,7 @@ async function fetchVaccination( }, ] : []), - ...toLinks("document", documents), + ...(await linksFor(userId, "document", documents)), ]); const catalog = vaccineCatalogName(row.antigenSlug, "en"); @@ -1460,7 +1390,7 @@ export async function fetchRecord( await loadOwner(userId), ); case "document": - return fetchDocument(userId, id, rid, origin, kinds); + return fetchDocument(userId, id, rid, origin); case "vaccination": return fetchVaccination(userId, id, rid, origin, kinds); } From 34302867aa607376db5a510b2edd988f1ebd4959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:24:28 +0100 Subject: [PATCH 2/3] feat(mcp): list links on lab and medication reads, ids on visit and condition rows get_labs readings (the latest, earlier values in full, trajectory readings) carry the fetch ids of what they link to, with linksTruncated; each linked record is described once per answer in linkedRecords, because one report usually holds a whole panel and would otherwise repeat on every reading. fetch lab: and fetch med: list their links like the other record kinds, and get_medication_schedule rows carry their id and links inline. get_visits and get_illness_recovery rows carry visit: and condition: ids, so a row's links are one fetch away. The labs and illness blocks gain an opt-in to carry row ids; the Coach never asks for them. --- src/app/mcp/__tests__/route.test.ts | 6 + src/lib/ai/coach/illness-snapshot.ts | 13 ++ src/lib/ai/coach/labs-snapshot.ts | 15 ++ .../tools-list.write-session.snap | 142 +++++++++++++++- .../mcp/__tests__/module-gate-sweep.test.ts | 6 + src/lib/mcp/__tests__/search-fetch.test.ts | 22 ++- src/lib/mcp/__tests__/tools.test.ts | 11 ++ src/lib/mcp/lab-reads.ts | 107 ++++++++++++ src/lib/mcp/rich-reads.ts | 16 +- src/lib/mcp/tools/fetch.ts | 160 ++++++++++++------ src/lib/mcp/tools/get-illness-recovery.ts | 53 +++++- src/lib/mcp/tools/get-labs.ts | 34 +++- src/lib/mcp/tools/get-medication-schedule.ts | 40 +++-- src/lib/mcp/tools/get-visits.ts | 5 +- src/lib/mcp/tools/output-shapes.ts | 22 +++ 15 files changed, 564 insertions(+), 88 deletions(-) create mode 100644 src/lib/mcp/lab-reads.ts diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 9a5589b20..d5c956613 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -52,6 +52,12 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool })); vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +// `get_labs` builds its labs block first and asks the Coach only on a miss; +// a miss keeps the health-reader case on the executor it asserts. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/mcp/server", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/lib/ai/coach/illness-snapshot.ts b/src/lib/ai/coach/illness-snapshot.ts index 09e66f52c..211b6adc6 100644 --- a/src/lib/ai/coach/illness-snapshot.ts +++ b/src/lib/ai/coach/illness-snapshot.ts @@ -107,6 +107,8 @@ export interface CoachIllnessBlock { bodySite?: string; /** The side of `bodySite`, when one is stated. */ laterality?: string; + /** The episode's id; set only for a read that asks for it (`withIds`). */ + id?: string; }>; /** Recently-resolved episodes, newest first — light history context. */ recentResolved: Array<{ @@ -116,6 +118,7 @@ export interface CoachIllnessBlock { resolvedAt: string; bodySite?: string; laterality?: string; + id?: string; }>; /** * v1.40 — the person's own symptoms over the last 14 days, most frequent @@ -208,8 +211,14 @@ export async function buildIllnessSnapshotBlock( * symptoms are history and are read from inside the limit only. */ reach: CoachHistoryReach = UNBOUNDED_REACH, + /** + * `withIds` carries each episode's id, so an MCP read can name the + * condition a client fetches next. The Coach never asks for it. + */ + options: { withIds?: boolean } = {}, ): Promise { if (!(await isIllnessEnabled(userId))) return null; + const idOf = (e: { id: string }) => (options.withIds ? { id: e.id } : {}); const floor = reachFloor(reach, now); const [activeRows, resolvedRows, symptoms] = await Promise.all([ @@ -222,6 +231,7 @@ export async function buildIllnessSnapshotBlock( }, orderBy: { onsetAt: "asc" }, select: { + id: true, label: true, type: true, lifecycle: true, @@ -240,6 +250,7 @@ export async function buildIllnessSnapshotBlock( orderBy: { resolvedAt: "desc" }, take: RESOLVED_HISTORY_LIMIT, select: { + id: true, label: true, type: true, onsetAt: true, @@ -267,6 +278,7 @@ export async function buildIllnessSnapshotBlock( lifecycle: e.lifecycle, onsetAt: e.onsetAt.toISOString(), ...siteFields(e), + ...idOf(e), })), recentResolved: resolvedRows.map((e) => ({ label: sanitizeLabel(e.label), @@ -275,6 +287,7 @@ export async function buildIllnessSnapshotBlock( // `resolvedAt` is non-null by the query filter above. resolvedAt: (e.resolvedAt as Date).toISOString(), ...siteFields(e), + ...idOf(e), })), ...(symptoms.length > 0 ? { symptoms } : {}), }; diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts index 85e102617..3b52a6132 100644 --- a/src/lib/ai/coach/labs-snapshot.ts +++ b/src/lib/ai/coach/labs-snapshot.ts @@ -128,6 +128,8 @@ export interface CoachLabReading { rangeStatus: "in-range" | "below" | "above" | "unknown"; /** Measured date (ISO). */ takenAt: string; + /** The lab result's id; set only for a read that asks for it (`withIds`). */ + id?: string; } /** One earlier numeric reading of a biomarker. */ @@ -136,6 +138,8 @@ export interface CoachLabHistoryPoint { /** in-range / below / above / unknown against that reading's own bounds. */ rangeStatus: "in-range" | "below" | "above" | "unknown"; takenAt: string; + /** The lab result's id; set only for a read that asks for it (`withIds`). */ + id?: string; } /** The readings of one calendar year (UTC), folded. */ @@ -174,6 +178,7 @@ export interface LabHistoryInput { value: number; takenAt: Date; rangeStatus: CoachLabHistoryPoint["rangeStatus"]; + id?: string; } const round2 = (value: number) => Math.round(value * 100) / 100; @@ -216,6 +221,7 @@ export function shapeLabHistory( value: reading.value, rangeStatus: reading.rangeStatus, takenAt: reading.takenAt.toISOString(), + ...(reading.id ? { id: reading.id } : {}), })), yearly, yearsOmitted: Math.max(0, years.length - LAB_HISTORY_MAX_YEARS), @@ -239,6 +245,11 @@ export async function buildLabsSnapshotBlock( * With it, each entry carries its `history`. */ analyte?: string; + /** + * Carry each reading's lab result id, so a caller can list what the + * reading is linked to. The Coach never asks: an id means nothing to it. + */ + withIds?: boolean; } = {}, ): Promise { if (!(await isModuleEnabled(userId, "labs"))) return null; @@ -272,6 +283,7 @@ export async function buildLabsSnapshotBlock( orderBy: { takenAt: "desc" }, take: MAX_ROWS, select: { + id: true, analyte: true, panel: true, value: true, @@ -318,6 +330,7 @@ export async function buildLabsSnapshotBlock( const resolved = resolveLabFields(row, row.biomarker); if (wanted && !resolved.analyte.toLowerCase().includes(wanted)) continue; const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`; + const id = options.withIds ? { id: row.id } : {}; const existing = entries.get(key); if (existing) { // One unit written two ways ("mg/dl", "mg/dL") is one series. @@ -333,6 +346,7 @@ export async function buildLabsSnapshotBlock( row.value, resolved.effectiveRange, ), + ...id, }); } continue; @@ -372,6 +386,7 @@ export async function buildLabsSnapshotBlock( resolved.effectiveRange, ), takenAt: row.takenAt.toISOString(), + ...id, }, }); } diff --git a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap index 7982c44bc..0abc8fcce 100644 --- a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap +++ b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap @@ -378,7 +378,7 @@ { "name": "get_labs", "title": "Get lab results", - "description": "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.", + "description": "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. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", "inputSchema": { "type": "object", "properties": { @@ -525,6 +525,15 @@ }, "takenAt": { "type": "string" + }, + "links": { + "type": "array", + "items": { + "type": "string" + } + }, + "linksTruncated": { + "type": "boolean" } }, "required": [ @@ -534,13 +543,67 @@ "referenceLow", "referenceHigh", "rangeStatus", - "takenAt" + "takenAt", + "links", + "linksTruncated" ], "additionalProperties": false } }, "nextCursor": { "type": "string" + }, + "linkedRecords": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "enum": [ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication" + ] + }, + "label": { + "type": "string" + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "sourceSystem": { + "type": "string", + "enum": [ + "PAPERLESS", + "PAPRA", + "OTHER" + ] + }, + "sourceId": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "label", + "date" + ], + "additionalProperties": false + } } }, "required": [ @@ -1672,7 +1735,7 @@ { "name": "get_illness_recovery", "title": "Get illness & recovery", - "description": "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", + "description": "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (each with its id, condition:, which fetch accepts and answers with the condition's linked visits and documents, and with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", "inputSchema": { "type": "object", "properties": {} @@ -2917,7 +2980,7 @@ { "name": "get_medication_schedule", "title": "Get the medication schedule", - "description": "Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: \"module_disabled\" } when the medications module is switched off.", + "description": "Fetch when the user's active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: its id (med:, which fetch accepts), name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, an intakeTracked flag, and its directly linked records (links: conditions, documents and visits, each with an id fetch accepts, a label, a date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId; [] when none; at most 25 with linksTruncated; documents only while the Documents module is on). As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: \"module_disabled\" } when the medications module is switched off.", "inputSchema": { "type": "object", "properties": {} @@ -2936,6 +2999,9 @@ "items": { "type": "object", "properties": { + "id": { + "type": "string" + }, "name": { "type": "string" }, @@ -2959,15 +3025,73 @@ }, "intakeTracked": { "type": "boolean" + }, + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "enum": [ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication" + ] + }, + "label": { + "type": "string" + }, + "date": { + "type": [ + "string", + "null" + ] + }, + "sourceSystem": { + "type": "string", + "enum": [ + "PAPERLESS", + "PAPRA", + "OTHER" + ] + }, + "sourceId": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "label", + "date" + ], + "additionalProperties": false + } + }, + "linksTruncated": { + "type": "boolean" } }, "required": [ + "id", "name", "dose", "nextDueAt", "overdue", "asNeeded", - "intakeTracked" + "intakeTracked", + "links", + "linksTruncated" ], "additionalProperties": false } @@ -3623,7 +3747,7 @@ { "name": "get_visits", "title": "Get past doctor visits", - "description": "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", + "description": "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its id (visit:, which fetch accepts and answers with the visit's linked records), its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", "inputSchema": { "type": "object", "properties": { @@ -3673,6 +3797,9 @@ "items": { "type": "object", "properties": { + "id": { + "type": "string" + }, "occurredAt": { "type": "string" }, @@ -3735,6 +3862,7 @@ } }, "required": [ + "id", "occurredAt", "status", "kind", @@ -3858,7 +3986,7 @@ { "name": "fetch", "title": "Fetch one health record", - "description": "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document or vaccination carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts); a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason \"module_disabled\" when the record's module is switched off.", + "description": "Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document, vaccination, lab or medication carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts, a label and a date, at most 25 with metadata.linksTruncated, and \"Linked records: none.\" in the text when there are none); a linked document imported from Paperless-ngx or Papra also names its sourceSystem and sourceId (never an address), and a document is listed only while the Documents module is on. A lab lists the links of its latest reading (the report it was read from, the visits it is filed against); a medication its conditions, documents and visits; a document also lists the lab results read from it and the medications filed against it; a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason \"module_disabled\" when the record's module is switched off.", "inputSchema": { "type": "object", "properties": { diff --git a/src/lib/mcp/__tests__/module-gate-sweep.test.ts b/src/lib/mcp/__tests__/module-gate-sweep.test.ts index 88e88aa78..0313cdabc 100644 --- a/src/lib/mcp/__tests__/module-gate-sweep.test.ts +++ b/src/lib/mcp/__tests__/module-gate-sweep.test.ts @@ -22,6 +22,12 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +// The labs read builds its block first and asks the Coach only on a miss; +// a miss here keeps every read on the executor this sweep counts. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/mcp/rich-reads", async (importOriginal) => ({ ...(await importOriginal()), getLabHistory: vi.fn(async () => ({ present: true, readings: [] })), diff --git a/src/lib/mcp/__tests__/search-fetch.test.ts b/src/lib/mcp/__tests__/search-fetch.test.ts index 1e35cc35a..ab21a939e 100644 --- a/src/lib/mcp/__tests__/search-fetch.test.ts +++ b/src/lib/mcp/__tests__/search-fetch.test.ts @@ -28,6 +28,19 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({ ...(await importOriginal()), loadRecordCandidates: vi.fn(async () => []), })); +// A medication or lab fetch lists its links through the link service; none +// are filed here (the links themselves are covered against Postgres in +// `tests/integration/mcp-record-links.test.ts`). +vi.mock("@/lib/links", () => ({ + listTargets: vi.fn(async () => []), + listTargetsBySource: vi.fn(async () => new Map()), +})); +// The lab fetch builds its block first and asks the Coach only on a miss; +// a miss here keeps the lab prose on the executor result each test states. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { findMany: vi.fn(), findFirst: vi.fn() }, @@ -291,9 +304,12 @@ describe("free-text fencing", () => { // The block is fenced... expect(result.text.startsWith(USER_TEXT_FENCE_START)).toBe(true); - expect(result.text.endsWith(USER_TEXT_FENCE_END)).toBe(true); - // ...and the payload cannot close it early: exactly one end marker, at the - // very end, so the injected sentence stays inside the data region. + // ...and the payload cannot close it early: exactly one end marker, right + // before the server's own links sentence, so the injected sentence stays + // inside the data region. + expect( + result.text.endsWith(`${USER_TEXT_FENCE_END} Linked records: none.`), + ).toBe(true); expect(result.text.split(USER_TEXT_FENCE_END)).toHaveLength(2); expect(result.text.split(USER_TEXT_FENCE_START)).toHaveLength(2); // The data itself is still delivered — fencing marks it, never drops it. diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 7c1730685..e4fa0aa3e 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -7,6 +7,17 @@ vi.mock("@/lib/ai/coach/tools/executor", () => ({ vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +// The labs read builds its block first and asks the Coach only on a miss; a +// miss here keeps the labs tests on the executor result each one states. +vi.mock("@/lib/ai/coach/labs-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildLabsSnapshotBlock: vi.fn(async () => null), +})); +// get_illness_recovery reads the illness block again for the condition ids. +vi.mock("@/lib/ai/coach/illness-snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildIllnessSnapshotBlock: vi.fn(async () => null), +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); diff --git a/src/lib/mcp/lab-reads.ts b/src/lib/mcp/lab-reads.ts new file mode 100644 index 000000000..e4f807095 --- /dev/null +++ b/src/lib/mcp/lab-reads.ts @@ -0,0 +1,107 @@ +/** + * The latest-per-marker labs read behind `get_labs` and `fetch lab:`, with + * what each reading is linked to. + * + * A present read builds the same block the Coach's `get_labs` returns + * (`buildLabsSnapshotBlock`, the whole record, as an MCP read has no lookback + * limit), asking it to carry each reading's lab result id. A miss falls back + * to the caller's Coach read, which owns the grounded empty answer (what the + * record holds, `analyte_not_found`). + * + * The links of a reading are the report it was read from (a document, behind + * the Documents module) and the visits it is filed against. A labs answer + * lists many readings, and one report usually holds a whole panel, so the + * answer names each linked record once, in `linkedRecords`, and a reading + * carries the ids of its links only. Every id there is one `fetch` accepts. + */ +import { buildLabsSnapshotBlock } from "@/lib/ai/coach/labs-snapshot"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; +import { boundLinks, linksBySource, type RecordLink } from "./record-links"; + +/** What a lab result lists as linked, in this order. */ +export const LAB_LINK_TARGETS = ["document", "encounter"] as const; + +/** The latest reading per marker with ids, or `fallback()` on a miss. */ +export async function readLatestLabs( + userId: string, + analyte: string | undefined, + fallback: () => Promise, +): Promise { + const data = await buildLabsSnapshotBlock( + userId, + new Date(), + UNBOUNDED_REACH, + { analyte, withIds: true }, + ); + return data ? { present: true, data } : fallback(); +} + +type IdReading = Record & { id: string }; + +/** Every reading of a labs answer that carries its lab result id. */ +function idReadings(result: unknown): IdReading[] { + const r = result as { + readings?: unknown[]; + data?: { recent?: Array<{ history?: { earlier?: unknown[] } }> }; + } | null; + const list: unknown[] = [ + ...(Array.isArray(r?.readings) ? r.readings : []), + ...(Array.isArray(r?.data?.recent) ? r.data.recent : []).flatMap( + (entry) => [entry, ...(entry?.history?.earlier ?? [])], + ), + ]; + return list.filter( + (x): x is IdReading => + typeof x === "object" && + x !== null && + typeof (x as { id?: unknown }).id === "string", + ); +} + +/** + * Replace each reading's lab result id with the ids of what it links to + * (`links`, bounded, with `linksTruncated`), and add the linked records once + * each as `linkedRecords`. A reading linked to nothing carries `links: []`. + * An answer without ids (a miss) comes back unchanged. `result` must be the + * caller's own copy: its readings are rewritten in place. + */ +export async function attachLabLinks( + userId: string, + result: unknown, +): Promise { + const readings = idReadings(result); + if (readings.length === 0) return result; + const bySource = await linksBySource( + userId, + "labResult", + [...new Set(readings.map((r) => r.id))], + LAB_LINK_TARGETS, + ); + const table = new Map(); + for (const reading of readings) { + const { links, linksTruncated } = boundLinks( + bySource.get(reading.id) ?? [], + ); + delete (reading as { id?: string }).id; + reading.links = links.flatMap((l) => (l.id ? [l.id] : [])); + reading.linksTruncated = linksTruncated; + for (const link of links) { + if (link.id && !table.has(link.id)) table.set(link.id, link); + } + } + return { ...(result as object), linkedRecords: [...table.values()] }; +} + +/** The links of one lab result, bounded, for a single-record `fetch`. */ +export async function labResultLinks( + userId: string, + labResultId: string, +): Promise<{ links: RecordLink[]; linksTruncated: boolean }> { + const bySource = await linksBySource( + userId, + "labResult", + [labResultId], + LAB_LINK_TARGETS, + ); + return boundLinks(bySource.get(labResultId) ?? []); +} diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 391afdd26..721798b34 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -1314,6 +1314,8 @@ export interface LabHistoryReading { rangeStatus: "in-range" | "below" | "above" | "unknown"; /** Measured date (ISO). */ takenAt: string; + /** The lab result's id; `get_labs` turns it into the reading's links. */ + id: string; } export interface LabHistoryResult { @@ -1331,14 +1333,10 @@ export interface LabHistoryResult { 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. + * ONE analyte's reading trajectory (newest first), through the resolver and + * range classifier the labs snapshot and the Labs API use. Paginated by + * `offset` (at most `limit` readings, `nextCursor` when more exist); never + * selects `noteEncrypted`. `{ present: false }` when nothing matches. */ export async function getLabHistory( userId: string, @@ -1393,6 +1391,7 @@ export async function getLabHistory( skip: offset, take: limit + 1, select: { + id: true, analyte: true, panel: true, value: true, @@ -1443,6 +1442,7 @@ export async function getLabHistory( resolved.effectiveRange, ), takenAt: row.takenAt.toISOString(), + id: row.id, }; }); diff --git a/src/lib/mcp/tools/fetch.ts b/src/lib/mcp/tools/fetch.ts index 01ca9aac0..75d6bb742 100644 --- a/src/lib/mcp/tools/fetch.ts +++ b/src/lib/mcp/tools/fetch.ts @@ -17,7 +17,18 @@ import { type NutrientsDailyResult, } from "@/lib/mcp/nutrients-read"; import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; -import { fetchRecord, isRecordKind } from "@/lib/mcp/record-search"; +import { + fetchRecord, + isRecordKind, + type FetchResult, +} from "@/lib/mcp/record-search"; +import { labResultLinks, readLatestLabs } from "@/lib/mcp/lab-reads"; +import { + boundLinks, + linksBySource, + linksSentence, + MEDICATION_LINK_TARGETS, +} from "@/lib/mcp/record-links"; import { plainClinicalText, plainLabText, @@ -26,6 +37,99 @@ import { } from "./render/citation-text"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; +/** The lab result id of the reading `plainLabText` quotes, when it has one. */ +function latestLabId(result: unknown): string | null { + const r = result as { present?: boolean; data?: { recent?: unknown[] } }; + const first = r?.present && Array.isArray(r.data?.recent) && r.data.recent[0]; + const id = first ? (first as { id?: unknown }).id : undefined; + return typeof id === "string" ? id : null; +} + +/** `fetch lab:`: the latest reading, with what it is linked to. */ +async function fetchLab( + userId: string, + id: string, + rid: string, + origin: string, +): Promise { + const result = await readLatestLabs(userId, rid, () => + executeCoachTool({ + userId, + name: "get_labs", + rawArguments: JSON.stringify({ analyte: rid }), + }), + ); + // The links of the reading the text quotes, the latest one. + const latestId = latestLabId(result); + const linked = latestId ? await labResultLinks(userId, latestId) : null; + return { + id, + title: rid, + text: + plainLabText(rid, result) + + (linked ? ` ${linksSentence(linked.links)}` : ""), + // Deep-link to the labs surface filtered to this analyte. + url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`, + metadata: { type: "lab", analyte: rid, ...linked }, + }; +} + +/** `fetch med:`: the medication, with what it is linked to. */ +async function fetchMedication( + userId: string, + id: string, + rid: string, + origin: string, +): Promise { + const med = await prisma.medication.findFirst({ + where: { id: rid, userId }, + select: { + name: true, + dose: true, + treatmentClass: true, + asNeeded: true, + }, + }); + if (!med) { + return { + id, + title: "Not found", + text: "No medication matches this id.", + url: `${origin}/medications`, + metadata: { type: "medication" }, + }; + } + // Plain-text prose, not a JSON blob. `name` / `dose` / + // `treatmentClass` are user-controlled free text returned as DATA, + // never interpreted — fenced so that contract is stated on the wire + // rather than only in the server instructions. + const text = fenceUserText( + `${med.name}${med.dose ? ` ${med.dose}` : ""}` + + `${med.asNeeded ? " (as needed)" : ""}` + + `${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`, + ); + const linked = await linksBySource( + userId, + "medication", + [rid], + MEDICATION_LINK_TARGETS, + ); + const { links, linksTruncated } = boundLinks(linked.get(rid) ?? []); + return { + id, + title: scrubFenceMarkers(med.name), + text: `${text} ${linksSentence(links)}`, + // Per-item deep-link to the medication detail page. + url: `${origin}/medications/${encodeURIComponent(rid)}`, + metadata: { + type: "medication", + medicationId: rid, + links, + linksTruncated, + }, + }; +} + /** * `fetch`, the second half of the `search` + `fetch` pair (see `search.ts` * for the wire shapes ChatGPT mandates): hydrates one id into a plain-text @@ -35,7 +139,7 @@ export const fetchTool: McpToolDefinition = { name: "fetch", title: "Fetch one health record", description: - 'Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document or vaccination carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts); a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason "module_disabled" when the record\'s module is switched off.', + 'Hydrate a single record returned by `search`, by its id (e.g. `metric:weight`, `med:`, `lab:LDL`, `visit:`, `condition:`, `document:`, `vaccination:`). Returns { id, title, text, url, metadata } where `text` is a server-authoritative, plain-text prose summary suitable for citation and `url` deep-links to the specific record in HealthLog. A visit, condition, document, vaccination, lab or medication carries its key fields and its directly linked records (metadata.links, each with an id `fetch` accepts, a label and a date, at most 25 with metadata.linksTruncated, and \"Linked records: none.\" in the text when there are none); a linked document imported from Paperless-ngx or Papra also names its sourceSystem and sourceId (never an address), and a document is listed only while the Documents module is on. A lab lists the links of its latest reading (the report it was read from, the visits it is filed against); a medication its conditions, documents and visits; a document also lists the lab results read from it and the medications filed against it; a document returns its metadata and at most a bounded excerpt of its own indexed text (the words of the stored document), never the file; a document held back from AI reading returns no excerpt and metadata.reason \"ai_read_deferred\". Fields that were never recorded are null. Returns a not-found message when the id does not resolve, and metadata.reason "module_disabled" when the record\'s module is switched off.', inputShape: { id: z.string().min(1).max(200) }, annotations: READ_ONLY_ANNOTATIONS, outputShape: { @@ -140,58 +244,10 @@ export const fetchTool: McpToolDefinition = { }; } - if (kind === "lab" && rid) { - const result = await executeCoachTool({ - userId: ctx.userId, - name: "get_labs", - rawArguments: JSON.stringify({ analyte: rid }), - }); - return { - id, - title: rid, - text: plainLabText(rid, result), - // Deep-link to the labs surface filtered to this analyte. - url: `${origin}/labs?analyte=${encodeURIComponent(rid)}`, - metadata: { type: "lab", analyte: rid }, - }; - } + if (kind === "lab" && rid) return fetchLab(ctx.userId, id, rid, origin); if (kind === "med" && rid) { - const med = await prisma.medication.findFirst({ - where: { id: rid, userId: ctx.userId }, - select: { - name: true, - dose: true, - treatmentClass: true, - asNeeded: true, - }, - }); - if (!med) { - return { - id, - title: "Not found", - text: "No medication matches this id.", - url: `${origin}/medications`, - metadata: { type: "medication" }, - }; - } - // Plain-text prose, not a JSON blob. `name` / `dose` / - // `treatmentClass` are user-controlled free text returned as DATA, - // never interpreted — fenced so that contract is stated on the wire - // rather than only in the server instructions. - const text = fenceUserText( - `${med.name}${med.dose ? ` ${med.dose}` : ""}` + - `${med.asNeeded ? " (as needed)" : ""}` + - `${med.treatmentClass ? ` — ${med.treatmentClass}` : ""}.`, - ); - return { - id, - title: scrubFenceMarkers(med.name), - text, - // Per-item deep-link to the medication detail page. - url: `${origin}/medications/${encodeURIComponent(rid)}`, - metadata: { type: "medication", medicationId: rid }, - }; + return fetchMedication(ctx.userId, id, rid, origin); } if (isRecordKind(kind) && rid) { diff --git a/src/lib/mcp/tools/get-illness-recovery.ts b/src/lib/mcp/tools/get-illness-recovery.ts index f2a2d7dc7..6be7a4776 100644 --- a/src/lib/mcp/tools/get-illness-recovery.ts +++ b/src/lib/mcp/tools/get-illness-recovery.ts @@ -1,4 +1,6 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; +import { buildIllnessSnapshotBlock } from "@/lib/ai/coach/illness-snapshot"; +import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach"; import { coachReadOutput } from "./output-shapes"; import { runCoachTool } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; @@ -48,17 +50,64 @@ function fenceIllnessSites(result: unknown): unknown { }; } +type IllnessEntry = Record & { id?: string }; + +/** + * Name each listed condition by the id `fetch` accepts (`condition:`), so + * its linked visits and documents are one call away. + * + * The Coach's block carries no ids (an id means nothing to it), so the block + * is read again by the same builder, asking for them, and its two lists take + * the place of the Coach's. Should the second read find nothing (a condition + * deleted in between), the Coach's lists stay as they were. + */ +async function withConditionIds( + userId: string, + result: unknown, +): Promise { + const r = result as { + data?: { illness?: Record } & Record; + }; + if (!r?.data?.illness) return result; + const fresh = await buildIllnessSnapshotBlock( + userId, + new Date(), + UNBOUNDED_REACH, + { withIds: true }, + ); + if (!fresh) return result; + const named = (list: IllnessEntry[]) => + list.map(({ id, ...entry }) => + id ? { id: `condition:${id}`, ...entry } : entry, + ); + return { + ...r, + data: { + ...r.data, + illness: { + ...r.data.illness, + restMode: fresh.restMode, + active: named(fresh.active), + recentResolved: named(fresh.recentResolved), + }, + }, + }; +} + export const getIllnessRecoveryTool: McpToolDefinition = { name: "get_illness_recovery", title: "Get illness & recovery", description: - "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", + "Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses (each with its id, condition:, which fetch accepts and answers with the condition's linked visits and documents, and with the body site and side when the user named one), the recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). Carries the rest-mode safety flag for framing. Returns { present: false } when there is nothing to report.", inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: coachReadOutput, async run(ctx, args) { return fenceIllnessSites( - await runCoachTool(ctx, "get_illness_recovery", args), + await withConditionIds( + ctx.userId, + await runCoachTool(ctx, "get_illness_recovery", args), + ), ); }, }; diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index f9c3dec33..3439d9e13 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -4,8 +4,10 @@ import { fenceUserText } from "@/lib/ai/coach/data-fence"; import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve"; import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads"; import { decodeOffsetCursor } from "@/lib/mcp/pagination"; +import { annotate } from "@/lib/logging/context"; +import { attachLabLinks, readLatestLabs } from "@/lib/mcp/lab-reads"; import type { McpAuthContext } from "../auth"; -import { coachReadOutput } from "./output-shapes"; +import { coachReadOutput, recordLinkSchema } from "./output-shapes"; import { runCoachTool, whenModuleOn } from "./shared"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; @@ -25,10 +27,16 @@ const getLabsOutput: z.ZodRawShape = { referenceHigh: z.number().nullable(), rangeStatus: z.enum(["in-range", "below", "above", "unknown"]), takenAt: z.string(), + // The fetch ids of what this reading links to; each is described + // once in `linkedRecords`. + links: z.array(z.string()), + linksTruncated: z.boolean(), }), ) .optional(), nextCursor: z.string().optional(), + // Both modes: every record a listed reading links to, named once. + linkedRecords: z.array(recordLinkSchema).optional(), }; /** The free-text fields of a lab reading: a document's words, never instructions. */ @@ -83,14 +91,30 @@ function readLabs( // Default: latest reading per biomarker via the Coach labs read. A name that // resolves to one of the user's markers (an alias or a catalog abbreviation // such as "GPT" for "ALT") is read as that marker's own name (v1.43). + // A present read carries each reading's lab result id (see `lab-reads.ts`); + // a miss is the Coach read's grounded answer. return (async () => { const asked = typeof args.analyte === "string" ? args.analyte : null; const marker = asked?.trim() ? await resolveMarkerForRead(ctx.userId, asked) : null; - return runCoachTool(ctx, "get_labs", { - ...(asked !== null ? { analyte: marker?.name ?? asked } : {}), + const analyte = asked !== null ? (marker?.name ?? asked) : undefined; + let missed = false; + const result = await readLatestLabs(ctx.userId, analyte, () => { + missed = true; + return runCoachTool( + ctx, + "get_labs", + analyte !== undefined ? { analyte } : {}, + ); }); + if (!missed) { + annotate({ + action: { name: "mcp.tool.invoked" }, + meta: { tool: "get_labs", present: true }, + }); + } + return result; })(); } @@ -98,7 +122,7 @@ export const getLabsTool: 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 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.", + "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. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. 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 @@ -119,7 +143,7 @@ export const getLabsTool: McpToolDefinition = { outputShape: getLabsOutput, run(ctx, args) { return whenModuleOn(ctx, "labs", "get_labs", async () => - fenceLabText(await readLabs(ctx, args)), + attachLabLinks(ctx.userId, fenceLabText(await readLabs(ctx, args))), ); }, }; diff --git a/src/lib/mcp/tools/get-medication-schedule.ts b/src/lib/mcp/tools/get-medication-schedule.ts index 27bcf479a..2f455385f 100644 --- a/src/lib/mcp/tools/get-medication-schedule.ts +++ b/src/lib/mcp/tools/get-medication-schedule.ts @@ -15,7 +15,13 @@ import { } from "@/lib/medications/scheduling/next-due"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; +import { + boundLinks, + linksBySource, + MEDICATION_LINK_TARGETS, +} from "@/lib/mcp/record-links"; import { MODULE_DISABLED } from "./shared"; +import { recordLinkSchema } from "./output-shapes"; import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types"; import { MEDICATION_SCHEDULE_ORDER } from "@/lib/medications/read-order"; @@ -33,12 +39,16 @@ const getMedicationScheduleOutput: z.ZodRawShape = { medications: z .array( z.object({ + // `med:`, the id `fetch` accepts. + id: z.string(), name: z.string(), dose: z.string().nullable(), nextDueAt: z.string().nullable(), overdue: z.boolean(), asNeeded: z.boolean(), intakeTracked: z.boolean(), + links: z.array(recordLinkSchema), + linksTruncated: z.boolean(), }), ) .optional(), @@ -48,7 +58,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { name: "get_medication_schedule", title: "Get the medication schedule", description: - 'Fetch when the user\'s active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, and an intakeTracked flag. As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.', + 'Fetch when the user\'s active medications are next due, and which are overdue right now. Reuses the same server-authoritative recurrence engine the medication cards render (open overdue slots win over future ones). Returns one row per active medication: its id (med:, which fetch accepts), name, dose, next-due instant, an overdue flag, an as-needed (PRN) flag, an intakeTracked flag, and its directly linked records (links: conditions, documents and visits, each with an id fetch accepts, a label, a date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId; [] when none; at most 25 with linksTruncated; documents only while the Documents module is on). As-needed medications and medications whose intake tracking is off (intakeTracked: false, kept as a record only) have no due time (nextDueAt: null). Returns { present: false } when no medications are tracked, and { present: false, reason: "module_disabled" } when the medications module is switched off.', inputShape: {}, annotations: READ_ONLY_ANNOTATIONS, outputShape: getMedicationScheduleOutput, @@ -140,15 +150,23 @@ export const getMedicationScheduleTool: McpToolDefinition = { // Under auto-take a rolling cadence stands where the job // will record next (see `auto-take.ts`). - const engineLastIntakeAt = await loadAutoTakeRollingAnchors(prisma, { - userId: ctx.userId, - medicationIds: medications - .filter((m) => m.autoMarkTaken) - .map((m) => m.id), - now, - userTz, - lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null, - }); + const [engineLastIntakeAt, linksByMedId] = await Promise.all([ + loadAutoTakeRollingAnchors(prisma, { + userId: ctx.userId, + medicationIds: medications + .filter((m) => m.autoMarkTaken) + .map((m) => m.id), + now, + userTz, + lastIntakeAt: (id) => lastTakenAtByMedId.get(id) ?? null, + }), + linksBySource( + ctx.userId, + "medication", + medications.map((m) => m.id), + MEDICATION_LINK_TARGETS, + ), + ]); const rows = medications.map((m) => { const display = computeDisplayDue({ @@ -168,6 +186,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { eraStart: eraStartByMedId.get(m.id) ?? null, }); return { + id: `med:${m.id}`, // The person's own words: fenced as data, as the server // instructions promise for medication names. name: fenceUserText(m.name), @@ -176,6 +195,7 @@ export const getMedicationScheduleTool: McpToolDefinition = { overdue: display ? display.overdue : false, asNeeded: m.asNeeded, intakeTracked: m.trackIntake, + ...boundLinks(linksByMedId.get(m.id) ?? []), }; }); diff --git a/src/lib/mcp/tools/get-visits.ts b/src/lib/mcp/tools/get-visits.ts index 2b2792ed4..3d1b47ccc 100644 --- a/src/lib/mcp/tools/get-visits.ts +++ b/src/lib/mcp/tools/get-visits.ts @@ -28,6 +28,8 @@ const getVisitsOutput: z.ZodRawShape = { visits: z .array( z.object({ + // `visit:`, the id `fetch` accepts for the visit and its links. + id: z.string(), occurredAt: z.string(), status: z.string(), kind: z.string(), @@ -58,7 +60,7 @@ export const getVisitsTool: McpToolDefinition = { name: "get_visits", title: "Get past doctor visits", description: - "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", + "Fetch the user's own past doctor visits over a bounded window (default: the last 12 months) so a question like 'when did I last see a cardiologist' is answerable from the record. Each visit carries its id (visit:, which fetch accepts and answers with the visit's linked records), its date, lifecycle status (DONE / CANCELLED / NO_SHOW — a no-show is not a visit that happened), kind, practitioner name + specialty, the visit's own free-text reason and outcome, the body site and side (set on procedures), and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring, or to one kind. kind PROCEDURE is the procedure and surgery history: without a months argument it reads the whole record, because a surgery years ago is still the answer to 'what surgeries have I had'. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none. With the illness module switched off the condition labels are withheld and conditionsReason is \"module_disabled\".", inputShape: { months: z .number() @@ -167,6 +169,7 @@ export const getVisitsTool: McpToolDefinition = { // entry is deleted, as the web record does. const seenBy = r.practitioner; return { + id: `visit:${r.id}`, occurredAt: r.occurredAt.toISOString(), status: r.status, kind: r.kind, diff --git a/src/lib/mcp/tools/output-shapes.ts b/src/lib/mcp/tools/output-shapes.ts index e08d575e8..c880ac764 100644 --- a/src/lib/mcp/tools/output-shapes.ts +++ b/src/lib/mcp/tools/output-shapes.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { isoDateTime } from "@/lib/validations/datetime"; +import { DOCUMENT_SOURCE_SYSTEMS } from "@/lib/validations/inbound-documents"; // ── Output schemas ─────────────────────────────────────────────────── // Every field except the presence anchor (`present`) is optional so a grounded @@ -66,3 +67,24 @@ export const dateRangeShape = z .describe( "An explicit inclusive date range. Use INSTEAD OF `window` for an arbitrary span (e.g. before vs after a date). Both bounds are ISO-8601 instants.", ); + +/** + * One linked record, as `fetch` lists it in `metadata.links` and the lab and + * medication reads list it (see `record-links.ts`). `id` is one `fetch` + * accepts; an imported document adds its source system and its id there. + */ +export const recordLinkSchema = z.object({ + id: z.string().nullable(), + kind: z.enum([ + "visit", + "condition", + "document", + "vaccination", + "lab", + "medication", + ]), + label: z.string(), + date: z.string().nullable(), + sourceSystem: z.enum(DOCUMENT_SOURCE_SYSTEMS).optional(), + sourceId: z.string().optional(), +}); From 5f9c8461a30844c91c1c578ae57e4b70b785d297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 2 Nov 2026 20:24:28 +0100 Subject: [PATCH 3/3] test(mcp): pin record links against Postgres Source system and id without the address, the Documents switch, the empty answer, the cap, document links to labs and medications, row ids, and that another account's document never appears, not even through a link row that points across accounts. --- tests/integration/mcp-record-links.test.ts | 420 +++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 tests/integration/mcp-record-links.test.ts diff --git a/tests/integration/mcp-record-links.test.ts b/tests/integration/mcp-record-links.test.ts new file mode 100644 index 000000000..ec4a29cad --- /dev/null +++ b/tests/integration/mcp-record-links.test.ts @@ -0,0 +1,420 @@ +/** + * The links an MCP read lists beside a lab reading, a medication and a + * document, against real Postgres and the real link tables. + * + * What the unit suites cannot show: that the link service's owner narrowing + * keeps another account's document out of every list (including through a + * link row that points across accounts), that a switched-off Documents module + * drops document links without reading them, that an imported document names + * its source system and id but never its instance address, and that the cap + * says when it cut a list short. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +process.env.APP_URL ??= "http://localhost"; + +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { linkTargets } from "@/lib/links"; +import { MCP_TOOLS } from "@/lib/mcp/tools"; +import { MAX_LINKS, type RecordLink } from "@/lib/mcp/record-links"; +import type { McpAuthContext } from "@/lib/mcp/auth"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ctx = (userId: string): McpAuthContext => ({ + userId, + tokenId: "t", + scopes: ["health:read"], + binding: `${userId}:t`, + canRead: true, + canWrite: false, +}); + +function tool(name: string) { + const def = MCP_TOOLS.find((t) => t.name === name); + if (!def) throw new Error(`tool ${name} not registered`); + return def; +} + +let counter = 0; + +async function makeUser(modules: Record = {}) { + const suffix = `${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `mcp-links-${suffix}`, + email: `mcp-links-${suffix}@example.test`, + timezone: "UTC", + modulePreferencesJson: modules, + }, + }); +} + +async function makeDocument( + userId: string, + title: string, + source?: { system: string; id: string }, +) { + return getPrismaClient().inboundDocument.create({ + data: { + userId, + title, + mimeType: "application/pdf", + byteSize: 4, + contentEncrypted: encryptToBytes("pdf!"), + documentDate: new Date("2026-03-01T00:00:00Z"), + ...(source + ? { + sourceSystem: source.system, + sourceId: source.id, + sourceInstance: "https://paperless.example.test", + } + : {}), + }, + }); +} + +async function makeLab(userId: string, analyte: string, takenAt: string) { + return getPrismaClient().labResult.create({ + data: { + userId, + analyte, + value: 120, + unit: "mg/dL", + takenAt: new Date(takenAt), + }, + }); +} + +/** + * One account's record: an LDL reading read from an imported report and filed + * against a visit, an older unlinked LDL reading, an HDL reading from the same + * report, a medication filed against the report, a condition. + */ +async function seedRecord(userId: string) { + const db = getPrismaClient(); + const report = await makeDocument(userId, "Lipid panel", { + system: "PAPERLESS", + id: "1234", + }); + const visit = await db.encounter.create({ + data: { + userId, + occurredAt: new Date("2026-03-01T09:00:00Z"), + kind: "ROUTINE", + }, + }); + const ldl = await makeLab(userId, "LDL", "2026-03-01T09:00:00Z"); + const olderLdl = await makeLab(userId, "LDL", "2025-03-01T09:00:00Z"); + const hdl = await makeLab(userId, "HDL", "2026-03-01T09:00:00Z"); + const med = await db.medication.create({ + data: { userId, name: "Atorvastatin", dose: "20mg" }, + }); + const condition = await db.illnessEpisode.create({ + data: { + userId, + label: "Knee pain", + type: "INJURY", + onsetAt: new Date("2026-02-01T00:00:00Z"), + }, + }); + await linkTargets(db, { + userId, + sourceKind: "labResult", + sourceId: ldl.id, + targetKind: "document", + targetIds: [report.id], + }); + await linkTargets(db, { + userId, + sourceKind: "labResult", + sourceId: hdl.id, + targetKind: "document", + targetIds: [report.id], + }); + await linkTargets(db, { + userId, + sourceKind: "encounter", + sourceId: visit.id, + targetKind: "labResult", + targetIds: [ldl.id], + }); + await linkTargets(db, { + userId, + sourceKind: "medication", + sourceId: med.id, + targetKind: "document", + targetIds: [report.id], + }); + return { report, visit, ldl, olderLdl, hdl, med, condition }; +} + +type Reading = { + analyte?: string; + links: string[]; + linksTruncated: boolean; + history?: { earlier: Array<{ links: string[]; linksTruncated: boolean }> }; +}; +type LabsAnswer = { + present: boolean; + data: { recent: Reading[] }; + linkedRecords: RecordLink[]; +}; +type FetchAnswer = { + title: string; + text: string; + metadata: Record & { + links?: RecordLink[]; + linksTruncated?: boolean; + }; +}; + +const fetchId = async (userId: string, id: string) => + (await tool("fetch").run(ctx(userId), { id })) as FetchAnswer; + +const analyteOf = (r: Reading) => + (r.analyte ?? "").replace(/<<<[A-Z_]+>>>/g, ""); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("get_labs lists each reading's links", () => { + it("names a linked report once, with its source system and id but no address", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const answer = (await tool("get_labs").run(ctx(user.id), {})) as LabsAnswer; + const ldl = answer.data.recent.find((x) => analyteOf(x) === "LDL")!; + const hdl = answer.data.recent.find((x) => analyteOf(x) === "HDL")!; + expect(ldl.links).toEqual([ + `document:${r.report.id}`, + `visit:${r.visit.id}`, + ]); + expect(ldl.linksTruncated).toBe(false); + expect(hdl.links).toEqual([`document:${r.report.id}`]); + // The report both readings came from is described once. + const docs = answer.linkedRecords.filter((l) => l.kind === "document"); + expect(docs).toEqual([ + { + id: `document:${r.report.id}`, + kind: "document", + label: "Lipid panel", + date: "2026-03-01", + sourceSystem: "PAPERLESS", + sourceId: "1234", + }, + ]); + expect(JSON.stringify(answer)).not.toContain("paperless.example.test"); + // No raw lab result id is left on the wire. + expect(JSON.stringify(answer)).not.toContain(r.ldl.id); + }); + + it("lists the links of earlier values and of a trajectory, [] when none", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const named = (await tool("get_labs").run(ctx(user.id), { + analyte: "LDL", + })) as LabsAnswer; + const [entry] = named.data.recent; + expect(entry.links).toEqual([ + `document:${r.report.id}`, + `visit:${r.visit.id}`, + ]); + expect(entry.history?.earlier).toEqual([ + expect.objectContaining({ links: [], linksTruncated: false }), + ]); + + const trajectory = (await tool("get_labs").run(ctx(user.id), { + analyte: "LDL", + history: true, + })) as { readings: Array<{ links: string[] }>; linkedRecords: unknown[] }; + expect(trajectory.readings.map((x) => x.links)).toEqual([ + [`document:${r.report.id}`, `visit:${r.visit.id}`], + [], + ]); + expect(trajectory.linkedRecords).toHaveLength(2); + }); + + it("drops document links while the Documents module is off", async () => { + const user = await makeUser({ inboundDocuments: false }); + const r = await seedRecord(user.id); + + const answer = (await tool("get_labs").run(ctx(user.id), {})) as LabsAnswer; + const ldl = answer.data.recent.find((x) => analyteOf(x) === "LDL")!; + expect(ldl.links).toEqual([`visit:${r.visit.id}`]); + expect(answer.linkedRecords.map((l) => l.kind)).toEqual(["visit"]); + + const lab = await fetchId(user.id, "lab:LDL"); + expect(lab.metadata.links?.map((l) => l.kind)).toEqual(["visit"]); + const med = await fetchId(user.id, `med:${r.med.id}`); + expect(med.metadata.links).toEqual([]); + expect(med.text).toContain("Linked records: none."); + }); +}); + +describe("fetch lists a lab's and a medication's links", () => { + it("lists the latest reading's links, with the source in the prose", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const lab = await fetchId(user.id, "lab:LDL"); + expect(lab.metadata.links?.map((l) => l.id)).toEqual([ + `document:${r.report.id}`, + `visit:${r.visit.id}`, + ]); + expect(lab.metadata.linksTruncated).toBe(false); + expect(lab.text).toContain("Linked records: document"); + expect(lab.text).toContain("PAPERLESS id"); + + const med = await fetchId(user.id, `med:${r.med.id}`); + expect(med.metadata.links).toEqual([ + expect.objectContaining({ + id: `document:${r.report.id}`, + sourceSystem: "PAPERLESS", + sourceId: "1234", + }), + ]); + }); + + it("says Linked records: none for a reading linked to nothing", async () => { + const user = await makeUser(); + await makeLab(user.id, "Ferritin", "2026-01-01T09:00:00Z"); + const lab = await fetchId(user.id, "lab:Ferritin"); + expect(lab.metadata.links).toEqual([]); + expect(lab.metadata.linksTruncated).toBe(false); + expect(lab.text).toContain("Linked records: none."); + }); + + it("caps a long list and says it did", async () => { + const user = await makeUser(); + const db = getPrismaClient(); + const med = await db.medication.create({ + data: { userId: user.id, name: "Ramipril", dose: "5mg" }, + }); + const docs = []; + for (let i = 0; i < MAX_LINKS + 3; i += 1) { + docs.push(await makeDocument(user.id, `Plan ${i}`)); + } + await linkTargets(db, { + userId: user.id, + sourceKind: "medication", + sourceId: med.id, + targetKind: "document", + targetIds: docs.map((d) => d.id), + }); + + const fetched = await fetchId(user.id, `med:${med.id}`); + expect(fetched.metadata.links).toHaveLength(MAX_LINKS); + expect(fetched.metadata.linksTruncated).toBe(true); + + const schedule = (await tool("get_medication_schedule").run( + ctx(user.id), + {}, + )) as { + medications: Array<{ + id: string; + links: RecordLink[]; + linksTruncated: boolean; + }>; + }; + expect(schedule.medications[0].id).toBe(`med:${med.id}`); + expect(schedule.medications[0].links).toHaveLength(MAX_LINKS); + expect(schedule.medications[0].linksTruncated).toBe(true); + }); +}); + +describe("fetch document lists the labs and medications filed against it", () => { + it("names the lab results read from it and the medications", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + const doc = await fetchId(user.id, `document:${r.report.id}`); + expect(doc.metadata.links?.map((l) => l.id)).toEqual([ + "lab:LDL", + "lab:HDL", + `med:${r.med.id}`, + ]); + expect(doc.text).toContain("Linked records: lab"); + expect(doc.text).toContain("medication"); + }); + + it("leaves out labs and medications whose module is off", async () => { + const user = await makeUser({ labs: false, medications: false }); + const r = await seedRecord(user.id); + const doc = await fetchId(user.id, `document:${r.report.id}`); + expect(doc.metadata.links).toEqual([]); + }); +}); + +describe("list rows carry the id fetch takes", () => { + it("on visits, the medication schedule and conditions", async () => { + const user = await makeUser(); + const r = await seedRecord(user.id); + + const visits = (await tool("get_visits").run(ctx(user.id), { + months: 60, + })) as { visits: Array<{ id: string }> }; + expect(visits.visits.map((v) => v.id)).toEqual([`visit:${r.visit.id}`]); + + const schedule = (await tool("get_medication_schedule").run( + ctx(user.id), + {}, + )) as { medications: Array<{ id: string; links: RecordLink[] }> }; + expect(schedule.medications.map((m) => m.id)).toEqual([`med:${r.med.id}`]); + expect(schedule.medications[0].links.map((l) => l.id)).toEqual([ + `document:${r.report.id}`, + ]); + + const illness = (await tool("get_illness_recovery").run( + ctx(user.id), + {}, + )) as { data: { illness: { active: Array<{ id: string }> } } }; + expect(illness.data.illness.active.map((c) => c.id)).toEqual([ + `condition:${r.condition.id}`, + ]); + }); +}); + +describe("another account's records never appear", () => { + it("not through its own links, nor through a link row pointing across", async () => { + const me = await makeUser(); + const other = await makeUser(); + const mine = await seedRecord(me.id); + const theirs = await seedRecord(other.id); + // A link row in my account naming the other account's report: the target + // re-read is narrowed to my account, so it must drop out. + await getPrismaClient().labResultDocumentLink.create({ + data: { + userId: me.id, + labResultId: mine.ldl.id, + documentId: theirs.report.id, + }, + }); + await getPrismaClient().medicationDocumentLink.create({ + data: { + userId: me.id, + medicationId: mine.med.id, + documentId: theirs.report.id, + }, + }); + + const labs = JSON.stringify(await tool("get_labs").run(ctx(me.id), {})); + const lab = JSON.stringify(await fetchId(me.id, "lab:LDL")); + const med = JSON.stringify(await fetchId(me.id, `med:${mine.med.id}`)); + const schedule = JSON.stringify( + await tool("get_medication_schedule").run(ctx(me.id), {}), + ); + for (const answer of [labs, lab, med, schedule]) { + expect(answer).toContain(mine.report.id); + expect(answer).not.toContain(theirs.report.id); + expect(answer).not.toContain(theirs.visit.id); + } + const foreign = await fetchId(me.id, `document:${theirs.report.id}`); + expect(foreign.title).toBe("Not found"); + const myDoc = JSON.stringify( + await fetchId(me.id, `document:${mine.report.id}`), + ); + expect(myDoc).not.toContain(theirs.med.id); + }); +});