diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index 477b5ab9d..10a7a6eef 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -7,9 +7,13 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/doctor-report-data", () => ({ + collectDoctorReportData: vi.fn(), +})); import { MCP_RESOURCES, MCP_RESOURCE_URIS } from "../resources"; import { prisma } from "@/lib/db"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; import type { McpAuthContext } from "../auth"; const CTX: McpAuthContext = { @@ -134,3 +138,84 @@ describe("healthlog://medications", () => { expect(result.count).toBe(0); }); }); + +describe("healthlog://report/doctor-visit", () => { + // The resource now reuses the SHARED summariser (doctor-visit-summary.ts), so + // it grounds the identical rich superset the doctor_visit_summary prompt does + // — mood, glucose panel, wellness composites, and illness episodes no longer + // drop out of the resource. + it("grounds the rich superset like the prompt when the data is present", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue({ + period: { + days: 90, + since: "2026-03-29T00:00:00.000Z", + start: "2026-03-29T00:00:00.000Z", + end: "2026-06-27T00:00:00.000Z", + }, + patient: { + username: "tester", + fullName: "A Person", + dateOfBirth: "1990-01-01", + gender: "female", + heightCm: 170, + }, + stats: { WEIGHT: { avg: 80, min: 79, max: 82, count: 30, latest: 80 } }, + glucoseClinical: { stillLearning: false, timeInRange: 70 }, + bmi: 24, + compliance: { Ramipril: { total: 90, taken: 86, skipped: 0, missed: 4 } }, + medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + mood: { avg: 3.5, min: 2, max: 5, count: 12 }, + wellnessScores: [{ type: "RECOVERY", latest: 70, avg: 65 }], + illnessEpisodes: [ + { + label: "Cold", + type: "ILLNESS", + lifecycle: "resolved", + onsetAt: "2026-05-01", + resolvedAt: "2026-05-08", + }, + ], + } as never); + + const result = (await resource("healthlog://report/doctor-visit").read( + CTX, + )) as Record; + + expect(collectDoctorReportData).toHaveBeenCalledWith( + "user-1", + expect.objectContaining({ days: 90 }), + ); + expect(result.present).toBe(true); + expect(result).toHaveProperty("vitals"); + expect(result).toHaveProperty("mood"); + expect(result).toHaveProperty("glucosePanel"); + expect(result).toHaveProperty("wellnessScores"); + expect(result).toHaveProperty("illnessEpisodes"); + // Patient name never leaks. + const patient = result.patient as Record; + expect(patient).not.toHaveProperty("fullName"); + expect(patient).not.toHaveProperty("username"); + }); + + it("omits absent sections honestly (no zero-fill)", async () => { + vi.mocked(collectDoctorReportData).mockResolvedValue({ + period: { days: 90, since: "", start: "", end: "" }, + patient: { dateOfBirth: "1990-01-01", gender: "female", heightCm: 170 }, + stats: {}, + glucoseClinical: { stillLearning: true }, + bmi: null, + compliance: {}, + medications: [], + mood: null, + } as never); + + const result = (await resource("healthlog://report/doctor-visit").read( + CTX, + )) as Record; + expect(result.present).toBe(true); + expect(result).not.toHaveProperty("mood"); + expect(result).not.toHaveProperty("glucosePanel"); + expect(result).not.toHaveProperty("wellnessScores"); + expect(result).not.toHaveProperty("illnessEpisodes"); + }); +}); diff --git a/src/lib/mcp/doctor-visit-summary.ts b/src/lib/mcp/doctor-visit-summary.ts new file mode 100644 index 000000000..23f4e8467 --- /dev/null +++ b/src/lib/mcp/doctor-visit-summary.ts @@ -0,0 +1,151 @@ +/** + * Shared doctor-visit summariser — the ONE reducer behind both the + * `healthlog://report/doctor-visit` resource (`resources.ts`) and the + * `doctor_visit_summary` prompt (`prompts.ts`). + * + * Both surfaces must ground IDENTICALLY: the same vitals, medications, + * adherence, labs, mood, glucose panel, wellness composites, and illness + * episodes, with the same units + server-side reference bands. They had + * drifted (the resource omitted mood / glucose panel / wellness / illness that + * the prompt carried); folding the richer superset into one function makes a + * future change reach both wires at once. + * + * Grounding discipline (ADR-004 / R-DEL-2): latest + aggregate per metric, + * latest reading per analyte — never the full per-reading history. Every + * numeric value is one the server already computed; every band is server-side. + * Absence is honest — a section is omitted rather than zero-filled. No verdict, + * no diagnosis. + */ +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; + +/** + * Fallback units for the headline specialised metric types the generic + * `metric-status-registry` intentionally omits (it carries the synced/additive + * metrics only). The doctor-report stats map is keyed by `MeasurementType`. + */ +const FALLBACK_UNITS: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_MASS_INDEX: "kg/m²", + SLEEP_DURATION: "min", +}; + +function unitAndBandFor(type: string): { + unit: string; + referenceBand: { low: number; high: number } | null; +} { + const meta = getMetricStatusMeta(type); + if (meta) { + return { + unit: meta.unit, + referenceBand: meta.normalRange + ? { low: meta.normalRange.low, high: meta.normalRange.high } + : null, + }; + } + return { unit: FALLBACK_UNITS[type] ?? "", referenceBand: null }; +} + +const round = (n: number): number => Math.round(n * 100) / 100; + +/** + * Reduce the full doctor-report payload to a compact, clinician-oriented, + * grounded summary. The richer superset both surfaces share. + */ +export function summariseForVisit( + data: DoctorReportData, +): Record { + const vitals = Object.entries(data.stats).map(([type, s]) => { + const { unit, referenceBand } = unitAndBandFor(type); + return { + metric: type, + unit, + referenceBand, + latest: round(s.latest), + avg: round(s.avg), + min: round(s.min), + max: round(s.max), + readings: s.count, + }; + }); + + const compliance = Object.entries(data.compliance).map(([name, c]) => ({ + medication: name, + takenDoses: c.taken, + expectedDoses: c.total, + missedDoses: c.missed, + adherencePct: + c.total > 0 ? Math.min(100, Math.round((c.taken / c.total) * 100)) : null, + })); + + const medications = data.medications + .slice(0, 40) + .map((m) => ({ name: m.name, dose: m.dose })); + + const labs = (data.labResults ?? []).slice(0, 50).map((l) => ({ + analyte: l.analyte, + panel: l.panel, + value: l.value, + valueText: l.valueText, + unit: l.unit, + referenceLow: l.referenceLow, + referenceHigh: l.referenceHigh, + takenAt: l.takenAt, + readings: l.count, + })); + + const wellness = (data.wellnessScores ?? []).map((w) => ({ + score: w.type, + latest: round(w.latest), + avg: round(w.avg), + note: "descriptive composite, not a clinical assessment", + })); + + const illness = (data.illnessEpisodes ?? []).map((e) => ({ + label: e.label, + type: e.type, + lifecycle: e.lifecycle, + onsetAt: e.onsetAt, + resolvedAt: e.resolvedAt, + })); + + return { + present: true, + period: { + days: data.period.days, + start: data.period.start, + end: data.period.end, + }, + patient: { + dateOfBirth: data.patient.dateOfBirth, + sex: data.patient.gender, + heightCm: data.patient.heightCm, + }, + bmi: data.bmi, + vitals, + medications, + compliance, + // Mood is privacy-gated upstream; include only the aggregate when present. + ...(data.mood + ? { + mood: { + avg: round(data.mood.avg), + min: data.mood.min, + max: data.mood.max, + entries: data.mood.count, + }, + } + : {}), + // Glucose clinical panel only when the user logs glucose (stillLearning + // flags a too-thin period — surfaced honestly, never asserted). + ...(data.glucoseClinical && !data.glucoseClinical.stillLearning + ? { glucosePanel: data.glucoseClinical } + : {}), + ...(labs.length > 0 ? { labs } : {}), + ...(wellness.length > 0 ? { wellnessScores: wellness } : {}), + ...(illness.length > 0 ? { illnessEpisodes: illness } : {}), + }; +} diff --git a/src/lib/mcp/prompts.ts b/src/lib/mcp/prompts.ts index 9270bacb2..7f6331b01 100644 --- a/src/lib/mcp/prompts.ts +++ b/src/lib/mcp/prompts.ts @@ -22,11 +22,8 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { - collectDoctorReportData, - type DoctorReportData, -} from "@/lib/doctor-report-data"; -import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { summariseForVisit } from "./doctor-visit-summary"; import { coachScopeWindowSchema } from "@/lib/ai/coach/types"; import type { CoachScopeWindow } from "@/lib/ai/coach/types"; import { executeCoachTool } from "@/lib/ai/coach/tools/executor"; @@ -71,136 +68,6 @@ export interface McpPromptDefinition { ) => Promise; } -/** - * Fallback units for the headline specialised metric types the generic - * `metric-status-registry` intentionally omits (it carries the synced/additive - * metrics only). The doctor-report stats map is keyed by `MeasurementType`. - */ -const FALLBACK_UNITS: Record = { - WEIGHT: "kg", - BLOOD_PRESSURE_SYS: "mmHg", - BLOOD_PRESSURE_DIA: "mmHg", - PULSE: "bpm", - BODY_MASS_INDEX: "kg/m²", - SLEEP_DURATION: "min", -}; - -function unitAndBandFor(type: string): { - unit: string; - referenceBand: { low: number; high: number } | null; -} { - const meta = getMetricStatusMeta(type); - if (meta) { - return { - unit: meta.unit, - referenceBand: meta.normalRange - ? { low: meta.normalRange.low, high: meta.normalRange.high } - : null, - }; - } - return { unit: FALLBACK_UNITS[type] ?? "", referenceBand: null }; -} - -const round = (n: number): number => Math.round(n * 100) / 100; - -/** - * Reduce the full doctor-report payload to a compact, clinician-oriented - * summary. Latest + aggregate per metric, latest reading per analyte — never - * the full per-reading history (R-DEL-2). Every numeric value is one the server - * computed; every band is server-side. No verdict, no diagnosis. - */ -function summariseForVisit(data: DoctorReportData): Record { - const vitals = Object.entries(data.stats).map(([type, s]) => { - const { unit, referenceBand } = unitAndBandFor(type); - return { - metric: type, - unit, - referenceBand, - latest: round(s.latest), - avg: round(s.avg), - min: round(s.min), - max: round(s.max), - readings: s.count, - }; - }); - - const compliance = Object.entries(data.compliance).map(([name, c]) => ({ - medication: name, - takenDoses: c.taken, - expectedDoses: c.total, - missedDoses: c.missed, - adherencePct: - c.total > 0 ? Math.min(100, Math.round((c.taken / c.total) * 100)) : null, - })); - - const medications = data.medications - .slice(0, 40) - .map((m) => ({ name: m.name, dose: m.dose })); - - const labs = (data.labResults ?? []).slice(0, 50).map((l) => ({ - analyte: l.analyte, - panel: l.panel, - value: l.value, - valueText: l.valueText, - unit: l.unit, - referenceLow: l.referenceLow, - referenceHigh: l.referenceHigh, - takenAt: l.takenAt, - readings: l.count, - })); - - const wellness = (data.wellnessScores ?? []).map((w) => ({ - score: w.type, - latest: round(w.latest), - avg: round(w.avg), - note: "descriptive composite, not a clinical assessment", - })); - - const illness = (data.illnessEpisodes ?? []).map((e) => ({ - label: e.label, - type: e.type, - lifecycle: e.lifecycle, - onsetAt: e.onsetAt, - resolvedAt: e.resolvedAt, - })); - - return { - period: { - days: data.period.days, - start: data.period.start, - end: data.period.end, - }, - patient: { - dateOfBirth: data.patient.dateOfBirth, - sex: data.patient.gender, - heightCm: data.patient.heightCm, - }, - bmi: data.bmi, - vitals, - medications, - compliance, - // Mood is privacy-gated upstream; include only the aggregate when present. - ...(data.mood - ? { - mood: { - avg: round(data.mood.avg), - min: data.mood.min, - max: data.mood.max, - entries: data.mood.count, - }, - } - : {}), - // Glucose clinical panel only when the user logs glucose (stillLearning - // flags a too-thin period — surfaced honestly, never asserted). - ...(data.glucoseClinical && !data.glucoseClinical.stillLearning - ? { glucosePanel: data.glucoseClinical } - : {}), - ...(labs.length > 0 ? { labs } : {}), - ...(wellness.length > 0 ? { wellnessScores: wellness } : {}), - ...(illness.length > 0 ? { illnessEpisodes: illness } : {}), - }; -} - /** * The framing block prepended to the assembled data. Injected once, centrally: * the assistant is told to organise + present, never to diagnose or invent. diff --git a/src/lib/mcp/resources.ts b/src/lib/mcp/resources.ts index d7b700cba..f497ef713 100644 --- a/src/lib/mcp/resources.ts +++ b/src/lib/mcp/resources.ts @@ -33,11 +33,8 @@ import { BIOMARKER_CATALOG, BIOMARKER_PANELS, } from "@/lib/labs/biomarker-catalog"; -import { - collectDoctorReportData, - type DoctorReportData, -} from "@/lib/doctor-report-data"; -import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { summariseForVisit } from "./doctor-visit-summary"; import type { McpAuthContext } from "./auth"; export interface McpResourceDefinition { @@ -269,97 +266,8 @@ export const MCP_RESOURCES: McpResourceDefinition[] = [ ]; // ── Doctor-visit summariser ────────────────────────────────────────── -// Reshapes the server-authoritative doctor-report payload into a compact, -// grounded summary — latest + aggregate per metric, latest reading per analyte. -// Never the full per-reading history (R-DEL-2); never a verdict. Every numeric -// value is one the server already computed; every band is server-side. - -const FALLBACK_UNITS: Record = { - WEIGHT: "kg", - BLOOD_PRESSURE_SYS: "mmHg", - BLOOD_PRESSURE_DIA: "mmHg", - PULSE: "bpm", - BODY_MASS_INDEX: "kg/m²", - SLEEP_DURATION: "min", -}; - -function unitAndBandFor(type: string): { - unit: string; - referenceBand: { low: number; high: number } | null; -} { - const meta = getMetricStatusMeta(type); - if (meta) { - return { - unit: meta.unit, - referenceBand: meta.normalRange - ? { low: meta.normalRange.low, high: meta.normalRange.high } - : null, - }; - } - return { unit: FALLBACK_UNITS[type] ?? "", referenceBand: null }; -} - -const round = (n: number): number => Math.round(n * 100) / 100; - -function summariseForVisit(data: DoctorReportData): Record { - const vitals = Object.entries(data.stats).map(([type, s]) => { - const { unit, referenceBand } = unitAndBandFor(type); - return { - metric: type, - unit, - referenceBand, - latest: round(s.latest), - avg: round(s.avg), - min: round(s.min), - max: round(s.max), - readings: s.count, - }; - }); - - const compliance = Object.entries(data.compliance).map(([name, c]) => ({ - medication: name, - takenDoses: c.taken, - expectedDoses: c.total, - missedDoses: c.missed, - adherencePct: - c.total > 0 ? Math.min(100, Math.round((c.taken / c.total) * 100)) : null, - })); - - const medications = data.medications - .slice(0, 40) - .map((m) => ({ name: m.name, dose: m.dose })); - - const labs = (data.labResults ?? []).slice(0, 50).map((l) => ({ - analyte: l.analyte, - panel: l.panel, - value: l.value, - valueText: l.valueText, - unit: l.unit, - referenceLow: l.referenceLow, - referenceHigh: l.referenceHigh, - takenAt: l.takenAt, - readings: l.count, - })); - - return { - present: true, - period: { - days: data.period.days, - start: data.period.start, - end: data.period.end, - }, - patient: { - dateOfBirth: data.patient.dateOfBirth, - sex: data.patient.gender, - heightCm: data.patient.heightCm, - }, - bmi: data.bmi, - vitals, - medications, - compliance, - ...(labs.length > 0 ? { labs } : {}), - }; -} +// The reducer is shared with the `doctor_visit_summary` prompt +// (`doctor-visit-summary.ts`) so both surfaces ground identically. async function readDoctorVisit( ctx: McpAuthContext,