mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
feat(mcp): list links on lab and medication reads, ids on visit and condition rows
get_labs readings (the latest, earlier values in full, trajectory readings) carry the fetch ids of what they link to, with linksTruncated; each linked record is described once per answer in linkedRecords, because one report usually holds a whole panel and would otherwise repeat on every reading. fetch lab: and fetch med: list their links like the other record kinds, and get_medication_schedule rows carry their id and links inline. get_visits and get_illness_recovery rows carry visit: and condition: ids, so a row's links are one fetch away. The labs and illness blocks gain an opt-in to carry row ids; the Coach never asks for them.
This commit is contained in:
1 parent
ea7c6c860e
commit
34302867aa
15 files changed
+564
-88
No files matched your search
@@ -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) ?? []);
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in new issue
Block a user