mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge branch 'feat/mcp-links' into release/v1.46.6
This commit is contained in:
commit
cae6edec64
19 files changed
+1272
-188
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
|
||||
buildLabsSnapshotBlock: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/mcp/server", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/mcp/server")>();
|
||||
return {
|
||||
|
||||
@@ -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<CoachIllnessBlock | null> {
|
||||
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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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<CoachLabsBlock | null> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:<id>, 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:<id>, 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:<id>, 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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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": {
|
||||
|
||||
@@ -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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
|
||||
buildLabsSnapshotBlock: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/mcp/rich-reads", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/mcp/rich-reads")>()),
|
||||
getLabHistory: vi.fn(async () => ({ present: true, readings: [] })),
|
||||
|
||||
@@ -28,6 +28,19 @@ vi.mock("@/lib/mcp/record-search", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/mcp/record-search")>()),
|
||||
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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
|
||||
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.
|
||||
|
||||
@@ -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<typeof import("@/lib/ai/coach/labs-snapshot")>()),
|
||||
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<typeof import("@/lib/ai/coach/illness-snapshot")>()),
|
||||
buildIllnessSnapshotBlock: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -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<unknown>,
|
||||
): Promise<unknown> {
|
||||
const data = await buildLabsSnapshotBlock(
|
||||
userId,
|
||||
new Date(),
|
||||
UNBOUNDED_REACH,
|
||||
{ analyte, withIds: true },
|
||||
);
|
||||
return data ? { present: true, data } : fallback();
|
||||
}
|
||||
|
||||
type IdReading = Record<string, unknown> & { 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<unknown> {
|
||||
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<string, RecordLink>();
|
||||
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) ?? []);
|
||||
}
|
||||
@@ -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<LinkTargetKind, ModuleKey | null> = {
|
||||
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<LinkTargetKind, "labResult">,
|
||||
{ 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<Map<string, RecordLink>> {
|
||||
// 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<string, RecordLink>();
|
||||
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<Map<string, Pick<RecordLink, "sourceSystem" | "sourceId">>> {
|
||||
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<string, Pick<RecordLink, "sourceSystem" | "sourceId">>();
|
||||
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<Map<string, RecordLink>> {
|
||||
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<string, Pick<RecordLink, "sourceSystem" | "sourceId">>();
|
||||
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<RecordLink[]> {
|
||||
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<LinkTargetKind[]> {
|
||||
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<Map<string, RecordLink[]>> {
|
||||
const out = new Map<string, RecordLink[]>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<RecordLink[]> {
|
||||
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<RecordKind>,
|
||||
): Promise<FetchResult> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+108
-52
@@ -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:<analyte>`: the latest reading, with what it is linked to. */
|
||||
async function fetchLab(
|
||||
userId: string,
|
||||
id: string,
|
||||
rid: string,
|
||||
origin: string,
|
||||
): Promise<FetchResult> {
|
||||
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:<id>`: the medication, with what it is linked to. */
|
||||
async function fetchMedication(
|
||||
userId: string,
|
||||
id: string,
|
||||
rid: string,
|
||||
origin: string,
|
||||
): Promise<FetchResult> {
|
||||
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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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:<id>`, `lab:LDL`, `visit:<id>`, `condition:<id>`, `document:<id>`, `vaccination:<id>`). 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) {
|
||||
|
||||
@@ -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<string, unknown> & { id?: string };
|
||||
|
||||
/**
|
||||
* Name each listed condition by the id `fetch` accepts (`condition:<id>`), 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<unknown> {
|
||||
const r = result as {
|
||||
data?: { illness?: Record<string, unknown> } & Record<string, unknown>;
|
||||
};
|
||||
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:<id>, 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),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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))),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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:<id>`, 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:<id>, 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) ?? []),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ const getVisitsOutput: z.ZodRawShape = {
|
||||
visits: z
|
||||
.array(
|
||||
z.object({
|
||||
// `visit:<id>`, 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:<id>, 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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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<string, boolean> = {}) {
|
||||
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<string, unknown> & {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user