mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(mcp): answer the visit history over the assistant wire
Add get_visits, a bounded read of past visits (default the last 12
months, optional practitioner-name filter) returning date, status,
kind, practitioner, specialty, the visit's own reason and outcome, and
the linked condition labels, newest first. Absence is explicit: an
account that has never recorded a visit reads { present: false }, never
{ present: true, visits: [] }.
Fold upcoming appointments into get_preventive_care as their own named
field rather than minting a second due-list tool, so a question about
what is coming up gets one answer. The Vorsorge arm stays free of
ENCOUNTER-origin reminders; the appointments are read from Encounter
directly. Reason and outcome ride the USER_TEXT wrapping, and every row
is scoped to the caller by construction.
This commit is contained in:
1 parent
79de1181d8
commit
a730ad188b
4 files changed
+429
-27
No files matched your search
@@ -66,28 +66,29 @@ optional so a `{ present: false }` miss and a full hit both validate.
|
||||
argument. Every read is a thin wrapper over an existing
|
||||
server-authoritative path; no new analytics is computed at the wire.
|
||||
|
||||
| Tool | What it returns |
|
||||
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
|
||||
| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
|
||||
| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
|
||||
| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
|
||||
| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
|
||||
| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
|
||||
| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
|
||||
| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
|
||||
| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
|
||||
| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
|
||||
| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
|
||||
| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
|
||||
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
|
||||
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
|
||||
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
|
||||
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
|
||||
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
|
||||
| `get_preventive_care` | The user's own configured preventive-care (Vorsorge) reminders — upcoming/overdue checkups with next-due dates. Surfaces configured reminders, invents nothing. |
|
||||
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
|
||||
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
|
||||
| Tool | What it returns |
|
||||
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `list_metrics` | One row per domain: whether data is present, an approximate sample count, and the tool that retrieves it. **Call this first.** |
|
||||
| `get_metric_series` | One metric's aggregate (count, min, max, mean, slope) plus recent-daily and weekly timelines, with units and population reference bands. |
|
||||
| `get_metrics` | Several metric series in one call — a paginated fan-out over `get_metric_series`, one grounded result per metric. |
|
||||
| `get_glucose_panel` | Per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). |
|
||||
| `get_sleep` | Per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). |
|
||||
| `get_workouts` | Most recent sessions (sport, duration, energy, distance, avg/max HR) plus a per-sport rollup over the window. |
|
||||
| `get_medication_compliance` | Cadence-aware adherence: dose-weighted compliance rate, expected vs taken/missed, current-cycle status, any GLP-1 titration context. |
|
||||
| `get_labs` | Latest reading per biomarker over the last 12 months (optionally one analyte). With `history:true` + an analyte, that analyte's paginated reading trajectory. |
|
||||
| `get_illness_recovery` | Rest mode, active and recently-resolved illnesses, recovery / strain composites, and the illness retrospective (recovery-gap, nadir, red flags). |
|
||||
| `get_cycle` | Menstrual-cycle context: phase + day-of-cycle, next predicted event, headline phase-correlation. Gated on cycle tracking; descriptive only. |
|
||||
| `get_correlations` | FDR-controlled day-to-next-day driver pairs between behaviours and outcomes, each with direction, lag, sample size, and a descriptive (never causal) note. |
|
||||
| `get_correlation` | The vetted, lag-aware association between **two named metrics**: direction, lag, sample size, Pearson r, and a descriptive note. |
|
||||
| `compare_metric` | One metric vs another over the same horizon, **or** one metric across two horizons (fixed windows or `{from,to}` ranges), with per-side stats + a delta. |
|
||||
| `get_metric_baseline` | Where the latest reading sits against the user's own usual range (median ± robust deviation), plus the strongest lagged driver. Needs ≥ 7 days of history. |
|
||||
| `detect_changepoints` | Points where a metric's level shifted over a window or `{from,to}` range — date, direction, before/after means. High firing bar. |
|
||||
| `get_medication_schedule` | When each active medication is next due and which are overdue right now — name, dose, next-due, overdue flag, as-needed flag. Reuses the recurrence engine. |
|
||||
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
|
||||
| `get_preventive_care` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. |
|
||||
| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. |
|
||||
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
|
||||
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
|
||||
|
||||
`search` + `fetch` are the de-facto two-tool retrieval convention and the
|
||||
**only** tools ChatGPT calls in its default (non-Developer) mode. Each
|
||||
|
||||
@@ -112,6 +112,8 @@ const READ_TOOLS = [
|
||||
"get_nutrients",
|
||||
"get_intraday_pulse",
|
||||
"get_ecg_recordings",
|
||||
// v1.38 — the bounded visit history.
|
||||
"get_visits",
|
||||
].sort();
|
||||
|
||||
/** A valid, narrow-scope (`health:read`) token resolution. */
|
||||
|
||||
@@ -47,6 +47,9 @@ vi.mock("@/lib/db", () => ({
|
||||
medicationScheduleRevision: { groupBy: vi.fn(async () => []) },
|
||||
integrationStatus: { findMany: vi.fn(async () => []) },
|
||||
measurementReminder: { findMany: vi.fn(async () => []) },
|
||||
// v1.38 — the visit history read + the appointments folded into
|
||||
// get_preventive_care both read the Encounter table.
|
||||
encounter: { findMany: vi.fn(async () => []) },
|
||||
// v1.30 (G1) — the nutrients pipeline.
|
||||
nutrientIntakeDay: {
|
||||
findMany: vi.fn(async () => []),
|
||||
@@ -72,6 +75,20 @@ vi.mock("@/lib/integrations/status", () => ({
|
||||
vi.mock("@/lib/measurement-reminders/dto", () => ({
|
||||
toMeasurementReminderDto: vi.fn((r) => r),
|
||||
}));
|
||||
// v1.38 — get_visits resolves its linked condition labels through the shared
|
||||
// link service; stub the batched read so the tool-wiring tests never reach a
|
||||
// DB. Its own logic is covered in the link-service suite.
|
||||
vi.mock("@/lib/links", () => ({
|
||||
listTargetsBySource: vi.fn(async () => new Map()),
|
||||
}));
|
||||
// v1.38 — get_visits decrypts a visit's reason/outcome; stub the codec so the
|
||||
// wiring tests never need a live ENCRYPTION key. The fail-soft decrypt path is
|
||||
// exercised by returning ciphertext bytes and asserting the fenced plaintext.
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((buf: Uint8Array) =>
|
||||
Buffer.from(buf).toString("utf8"),
|
||||
),
|
||||
}));
|
||||
// v1.30 (G1) — `get_nutrients` delegates to the nutrients-read engine; stub
|
||||
// only the DB-touching entry point so the tool-wiring tests below never reach
|
||||
// a real engine. `NUTRIENT_LABELS` / `resolveNutrientCode` stay real (pure,
|
||||
@@ -137,6 +154,8 @@ import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { getNutrients } from "@/lib/mcp/nutrients-read";
|
||||
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
|
||||
import { listTargetsBySource } from "@/lib/links";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const CTX: McpAuthContext = {
|
||||
@@ -193,6 +212,8 @@ describe("MCP tool registry — surface", () => {
|
||||
"get_intraday_pulse",
|
||||
// v1.30 coverage review (G3) — ECG recording metadata.
|
||||
"get_ecg_recordings",
|
||||
// v1.38 — the bounded visit history.
|
||||
"get_visits",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
@@ -639,6 +660,13 @@ describe("get_integration_status", () => {
|
||||
});
|
||||
|
||||
describe("get_preventive_care", () => {
|
||||
// The global beforeEach resets every mock, so re-arm the appointments read
|
||||
// (folded in v1.38) to an empty list; the checkup-focused cases below leave
|
||||
// it empty and the appointments fold is exercised in its own describe.
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("surfaces the configured reminder due-list with overdue flags", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([
|
||||
{ id: "r-1" },
|
||||
@@ -785,6 +813,147 @@ describe("get_preventive_care", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_preventive_care — upcoming appointments fold in", () => {
|
||||
it("carries booked appointments as a named field alongside the checkups", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "enc-2",
|
||||
occurredAt: new Date("2030-01-01T09:00:00.000Z"),
|
||||
status: "PLANNED",
|
||||
kind: "ROUTINE",
|
||||
reasonEncrypted: null,
|
||||
practitioner: { name: "Dr. Wolke", specialty: "General practice" },
|
||||
},
|
||||
] as never);
|
||||
const result = (await tool("get_preventive_care").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
checkups?: unknown;
|
||||
appointments: Array<Record<string, unknown>>;
|
||||
};
|
||||
// No Vorsorge reminder, but an appointment exists → present, appointments
|
||||
// carried, and no empty `checkups` array left dangling.
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.checkups).toBeUndefined();
|
||||
expect(result.appointments).toHaveLength(1);
|
||||
expect(result.appointments[0]).toMatchObject({
|
||||
practitioner: "Dr. Wolke",
|
||||
specialty: "General practice",
|
||||
kind: "ROUTINE",
|
||||
reason: null,
|
||||
});
|
||||
// It reads FUTURE, PLANNED visits only — never the past history.
|
||||
const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.where.status).toBe("PLANNED");
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("stays { present: false } when neither a reminder nor an appointment exists", async () => {
|
||||
vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
const result = (await tool("get_preventive_care").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
};
|
||||
expect(result.present).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get_visits", () => {
|
||||
function visitRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "enc-1",
|
||||
occurredAt: new Date("2026-06-01T09:00:00.000Z"),
|
||||
status: "DONE",
|
||||
kind: "SPECIALIST",
|
||||
reasonEncrypted: Buffer.from("chest pain follow-up", "utf8"),
|
||||
outcomeEncrypted: Buffer.from("all clear", "utf8"),
|
||||
practitioner: { name: "Dr. Herz", specialty: "Cardiology" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns the bounded list, newest first, with fenced free text and linked conditions", async () => {
|
||||
vi.mocked(decryptFromBytes).mockImplementation((buf: Uint8Array) =>
|
||||
Buffer.from(buf).toString("utf8"),
|
||||
);
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([
|
||||
visitRow(),
|
||||
] as never);
|
||||
vi.mocked(listTargetsBySource).mockResolvedValue(
|
||||
new Map([["enc-1", [{ id: "ep-1", label: "Hypertension", date: null }]]]),
|
||||
);
|
||||
|
||||
const result = (await tool("get_visits").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
windowMonths: number;
|
||||
visits: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.windowMonths).toBe(12);
|
||||
expect(result.visits).toHaveLength(1);
|
||||
const v = result.visits[0];
|
||||
expect(v.status).toBe("DONE");
|
||||
expect(v.kind).toBe("SPECIALIST");
|
||||
expect(v.practitioner).toBe("Dr. Herz");
|
||||
expect(v.specialty).toBe("Cardiology");
|
||||
// Reason + outcome ride the USER_TEXT wrapping.
|
||||
expect(v.reason).toContain("<<<USER_TEXT_START>>>");
|
||||
expect(v.reason).toContain("chest pain follow-up");
|
||||
expect(v.outcome).toContain("all clear");
|
||||
expect(v.conditions).toEqual(["Hypertension"]);
|
||||
});
|
||||
|
||||
// WR-10 — the absence contract, the check that must never be decorative. An
|
||||
// empty list is honest absence, not empty success. { present: true,
|
||||
// visits: [] } would tell the model "you have no visits" when the truth is
|
||||
// "you have never recorded one".
|
||||
it("returns { present: false } — never { present: true, visits: [] } — when the account has never recorded a visit", async () => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
const result = (await tool("get_visits").run(CTX, {})) as {
|
||||
present: boolean;
|
||||
visits?: unknown;
|
||||
};
|
||||
expect(result.present).toBe(false);
|
||||
expect(result).not.toHaveProperty("visits");
|
||||
expect(result.visits).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scopes the read to the caller and never accepts a userId argument", async () => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
await tool("get_visits").run(CTX, { userId: "someone-else", months: 6 });
|
||||
const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
take: number;
|
||||
};
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(arg.where.deletedAt).toBeNull();
|
||||
expect(arg.take).toBe(50);
|
||||
// The window arg narrows the range; the injected userId body field is
|
||||
// ignored entirely.
|
||||
expect(arg.where.occurredAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("narrows to one practitioner by a case-insensitive name substring", async () => {
|
||||
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
|
||||
await tool("get_visits").run(CTX, { practitioner: "herz" });
|
||||
const arg = vi.mocked(prisma.encounter.findMany).mock.calls[0][0] as {
|
||||
where: {
|
||||
practitioner?: { is?: { name?: { contains?: string; mode?: string } } };
|
||||
};
|
||||
};
|
||||
expect(arg.where.practitioner?.is?.name).toEqual({
|
||||
contains: "herz",
|
||||
mode: "insensitive",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("search — cursor pagination", () => {
|
||||
it("returns a bounded page and an opaque nextCursor when more results exist", async () => {
|
||||
// 60 lab analytes → exceeds the 50-result page.
|
||||
|
||||
+235
-5
@@ -69,6 +69,8 @@ import {
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
|
||||
import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { listTargetsBySource } from "@/lib/links";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
/**
|
||||
@@ -872,7 +874,16 @@ const getIntegrationStatusOutput: z.ZodRawShape = {
|
||||
.optional(),
|
||||
};
|
||||
|
||||
/** Output schema for `get_preventive_care` — the Vorsorge due-list. */
|
||||
/**
|
||||
* Output schema for `get_preventive_care` — the Vorsorge due-list, plus the
|
||||
* upcoming appointments booked as visits.
|
||||
*
|
||||
* `appointments` is a NAMED field on this same result rather than a second
|
||||
* due-list tool: an assistant asked "what is coming up" gets one answer instead
|
||||
* of having to know there are two lists. The Vorsorge `checkups` arm stays free
|
||||
* of `ENCOUNTER`-origin reminders (Phase 1 excluded them); the appointments are
|
||||
* read from `Encounter` directly, so the two never blur.
|
||||
*/
|
||||
const getPreventiveCareOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
checkups: z
|
||||
@@ -887,8 +898,73 @@ const getPreventiveCareOutput: z.ZodRawShape = {
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
appointments: z
|
||||
.array(
|
||||
z.object({
|
||||
occurredAt: z.string(),
|
||||
practitioner: z.string().nullable(),
|
||||
specialty: z.string().nullable(),
|
||||
kind: z.string(),
|
||||
reason: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Output schema for `get_visits` — the account's own past visits, bounded.
|
||||
*
|
||||
* Follows `get_preventive_care`'s shape: every field beyond `present` is
|
||||
* optional so a grounded `{ present: false }` miss and a full hit both validate
|
||||
* against one schema (the ChatGPT Apps-SDK conformance rule the other reads
|
||||
* follow). Absence is `{ present: false }`, never `{ present: true, visits: [] }`
|
||||
* — "you have never recorded a visit" and "you have no visits" are different
|
||||
* claims and only the first is true when the list is empty.
|
||||
*/
|
||||
const getVisitsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
windowMonths: z.number().optional(),
|
||||
visits: z
|
||||
.array(
|
||||
z.object({
|
||||
occurredAt: z.string(),
|
||||
status: z.string(),
|
||||
kind: z.string(),
|
||||
practitioner: z.string().nullable(),
|
||||
specialty: z.string().nullable(),
|
||||
reason: z.string().nullable(),
|
||||
outcome: z.string().nullable(),
|
||||
conditions: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
/** Default trailing window for `get_visits` when the caller names none. */
|
||||
const DEFAULT_VISITS_WINDOW_MONTHS = 12;
|
||||
/** Hard cap on the `get_visits` window, so an unbounded read is impossible. */
|
||||
const MAX_VISITS_WINDOW_MONTHS = 60;
|
||||
/** Newest-first cap on the visits one `get_visits` call returns. */
|
||||
const MAX_VISITS = 50;
|
||||
/** Newest-first cap on the appointments folded into `get_preventive_care`. */
|
||||
const MAX_APPOINTMENTS = 25;
|
||||
|
||||
/**
|
||||
* Decrypt a visit's `Bytes` free-text column, fail-soft to null.
|
||||
*
|
||||
* A key-rotation gap on one row reads as a missing reason/outcome, never as a
|
||||
* thrown tool call that takes the whole list down. Same codec + fail-soft
|
||||
* contract as `src/lib/encounters/dto.ts`.
|
||||
*/
|
||||
function decryptVisitText(value: Uint8Array | null): string | null {
|
||||
if (!value || value.byteLength === 0) return null;
|
||||
try {
|
||||
return decryptFromBytes(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output schema for `get_nutrients` — either the presence overview (no
|
||||
* `nutrient` arg) or one nutrient's per-day series + reference. Every field
|
||||
@@ -1546,11 +1622,43 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
name: "get_preventive_care",
|
||||
title: "Get preventive-care due-list",
|
||||
description:
|
||||
"Fetch the user's own configured preventive-care (Vorsorge) reminders — upcoming and overdue checkups with their next-due dates. Surfaces only the reminders the user has already set up (it never invents screening recommendations). Each item carries its label, optional measurement type, next-due instant, an overdue flag, and last-completed date. Returns { present: false } when no reminders are configured.",
|
||||
"Fetch what preventive care is coming up: the user's own configured preventive-care (Vorsorge) reminders (upcoming and overdue checkups with their next-due dates) AND the appointments they have booked as future visits. Surfaces only the reminders the user has already set up (it never invents screening recommendations); each checkup carries its label, optional measurement type, next-due instant, an overdue flag, and last-completed date. Each `appointments` entry carries its date, practitioner name + specialty, visit kind, and the visit's own free-text reason. Returns { present: false } only when neither a reminder nor an appointment exists.",
|
||||
inputShape: {},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getPreventiveCareOutput,
|
||||
async run(ctx) {
|
||||
const now = new Date();
|
||||
// Upcoming appointments ride alongside the Vorsorge due-list, read from
|
||||
// `Encounter` (a booked visit in the future). They are a NAMED field on
|
||||
// this result, not a second tool, so "what is coming up" is one answer.
|
||||
const appointmentRows = await prisma.encounter.findMany({
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
deletedAt: null,
|
||||
status: "PLANNED",
|
||||
occurredAt: { gt: now },
|
||||
},
|
||||
orderBy: { occurredAt: "asc" },
|
||||
take: MAX_APPOINTMENTS,
|
||||
include: { practitioner: { select: { name: true, specialty: true } } },
|
||||
});
|
||||
const appointments = appointmentRows.map((r) => {
|
||||
const reason = decryptVisitText(r.reasonEncrypted);
|
||||
return {
|
||||
occurredAt: r.occurredAt.toISOString(),
|
||||
practitioner: r.practitioner
|
||||
? scrubFenceMarkers(r.practitioner.name)
|
||||
: null,
|
||||
specialty: r.practitioner?.specialty
|
||||
? scrubFenceMarkers(r.practitioner.specialty)
|
||||
: null,
|
||||
kind: r.kind,
|
||||
// A visit's reason is user- and document-derived free text, so it
|
||||
// rides the same USER_TEXT wrapping every other free-text field uses.
|
||||
reason: reason !== null ? fenceUserText(reason) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
// A booked visit's one-shot reminder rides this same engine with
|
||||
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
|
||||
@@ -1570,7 +1678,11 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
if (reminders.length === 0) {
|
||||
// Present when EITHER arm carries something: a person with a booked
|
||||
// appointment but no configured Vorsorge reminder still has preventive
|
||||
// care coming up, and hiding the appointment behind `present: false`
|
||||
// would be the silent-zero this contract forbids.
|
||||
if (reminders.length === 0 && appointments.length === 0) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_preventive_care", present: false },
|
||||
@@ -1587,7 +1699,6 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
const checkups = reminders.map((r) => {
|
||||
const dto = toMeasurementReminderDto(r);
|
||||
return {
|
||||
@@ -1607,7 +1718,11 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_preventive_care", present: true },
|
||||
});
|
||||
return { present: true, checkups };
|
||||
return {
|
||||
present: true,
|
||||
...(checkups.length > 0 ? { checkups } : {}),
|
||||
...(appointments.length > 0 ? { appointments } : {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── v1.30 coverage review (G1) — the nutrients pipeline ─────────────
|
||||
@@ -1782,6 +1897,121 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── v1.38 — the visit history, bounded ──────────────────────────────
|
||||
{
|
||||
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, and the labels of any conditions it was filed against. Optionally narrow to one practitioner by a name substring. Newest first, bounded. Returns { present: false } when the user has never recorded a visit — distinct from a filtered read that simply matched none.",
|
||||
inputShape: {
|
||||
months: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(MAX_VISITS_WINDOW_MONTHS)
|
||||
.optional()
|
||||
.describe(
|
||||
"Trailing window in months. Defaults to 12, capped at 60. Past visits only — a future appointment is surfaced by get_preventive_care, not here.",
|
||||
),
|
||||
practitioner: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional case-insensitive name substring to narrow to one practitioner (e.g. a surname read back from an earlier result). Omit for every practitioner.",
|
||||
),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getVisitsOutput,
|
||||
async run(ctx, args) {
|
||||
const months =
|
||||
typeof args.months === "number"
|
||||
? args.months
|
||||
: DEFAULT_VISITS_WINDOW_MONTHS;
|
||||
const practitioner =
|
||||
typeof args.practitioner === "string" && args.practitioner.trim()
|
||||
? args.practitioner.trim()
|
||||
: undefined;
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now);
|
||||
cutoff.setMonth(cutoff.getMonth() - months);
|
||||
|
||||
const rows = await prisma.encounter.findMany({
|
||||
// `userId` is the resolved session's, never an argument — a visit
|
||||
// belonging to another account is unreachable by construction.
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
deletedAt: null,
|
||||
occurredAt: { gte: cutoff, lte: now },
|
||||
...(practitioner
|
||||
? {
|
||||
practitioner: {
|
||||
is: {
|
||||
name: { contains: practitioner, mode: "insensitive" },
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { occurredAt: "desc" },
|
||||
take: MAX_VISITS,
|
||||
include: { practitioner: { select: { name: true, specialty: true } } },
|
||||
});
|
||||
|
||||
// Absence is explicit. An empty list is { present: false } — "you have
|
||||
// never recorded a visit" — NEVER { present: true, visits: [] }, which
|
||||
// would read as "you have no visits", a claim the empty read cannot make.
|
||||
if (rows.length === 0) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_visits", present: false },
|
||||
});
|
||||
return { present: false };
|
||||
}
|
||||
|
||||
// The linked condition labels, one grouped read for the whole page, from
|
||||
// the same link service every other visit surface goes through.
|
||||
const conditionsBySource = await listTargetsBySource(prisma, {
|
||||
userId: ctx.userId,
|
||||
sourceKind: "encounter",
|
||||
sourceIds: rows.map((r) => r.id),
|
||||
targetKind: "conditionEpisode",
|
||||
});
|
||||
|
||||
const visits = rows.map((r) => {
|
||||
const reason = decryptVisitText(r.reasonEncrypted);
|
||||
const outcome = decryptVisitText(r.outcomeEncrypted);
|
||||
return {
|
||||
occurredAt: r.occurredAt.toISOString(),
|
||||
status: r.status,
|
||||
kind: r.kind,
|
||||
practitioner: r.practitioner
|
||||
? scrubFenceMarkers(r.practitioner.name)
|
||||
: null,
|
||||
specialty: r.practitioner?.specialty
|
||||
? scrubFenceMarkers(r.practitioner.specialty)
|
||||
: null,
|
||||
// Reason and outcome are user- and document-derived free text, so
|
||||
// they ride the USER_TEXT wrapping; the markers are stripped from the
|
||||
// content they wrap. A condition label is the user's own free text
|
||||
// too — scrubbed of any forged marker before it enters the payload.
|
||||
reason: reason !== null ? fenceUserText(reason) : null,
|
||||
outcome: outcome !== null ? fenceUserText(outcome) : null,
|
||||
conditions: (conditionsBySource.get(r.id) ?? []).map((c) =>
|
||||
scrubFenceMarkers(c.label),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_visits", present: true },
|
||||
});
|
||||
return { present: true, windowMonths: months, visits };
|
||||
},
|
||||
},
|
||||
...searchAndFetchTools(),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user