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/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/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); } 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(), +}); 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); + }); +});