mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
82b43f135e
commit
1d9cc4b3d2
4 files changed
+242
-231
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user