refactor(mcp): share one doctor-visit summariser across the resource and prompt

The doctor-visit summariser was duplicated and had drifted: the
healthlog://report/doctor-visit resource omitted mood, the glucose
panel, the wellness composites, and illness episodes that the
doctor_visit_summary prompt already carried. Extract one shared reducer
(doctor-visit-summary.ts) using the richer superset and have both
surfaces call it, so they ground identically and a future change reaches
both wires at once. Both keep the grounded latest+aggregate shape and
omit absent sections honestly.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 02:00:26 +02:00
1 parent 82b43f135e
commit 1d9cc4b3d2
4 files changed
+242 -231

No files matched your search

+85
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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");
});
});
+151
View File
@@ -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<string, string> = {
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<string, unknown> {
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 } : {}),
};
}
+2 -135
View File
@@ -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<McpPromptResult>;
}
/**
* 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<string, string> = {
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<string, unknown> {
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.
+4 -96
View File
@@ -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<string, string> = {
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<string, unknown> {
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,