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] 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); }