mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(mcp): add deep-value reads and a doctor-visit prompt
Expose four read-only relational tools and one prompt over the MCP wire,
each a thin re-export of an existing server-authoritative engine — no new
statistics, no new business logic:
- get_correlation: the FDR-controlled, lag-aware association between two
named metrics, selected out of the existing correlation discovery the
Coach and the insight route already run. Descriptive, never causal.
- compare_metric: two metrics over the same trailing window, or one metric
across two trailing windows, computed over the WMY rollup readers with a
delta only when both sides share a unit.
- get_metric_baseline: where today's reading sits against the user's own
usual range (median ± robust deviation) plus the strongest lagged driver,
reusing the metric-page baseline builder. Honest-null below the learning
floor — never a fabricated range.
- detect_changepoints: level shifts over the rollup tier via a small in-repo
CUSUM binary segmentation with a high firing bar.
- doctor_visit_summary prompt: a structured, grounded visit-prep summary
assembled from the doctor-report data path so the numbers match the
exported PDF, with the grounding framing injected once — data and context
only, no diagnosis or verdict.
Every tool returns values, units, reference bands and provenance, declares
read-only / non-destructive / closed-world annotations plus an output schema,
and reports { present: false } for absence. All reads stay session-narrowed
and read-only; the existing security posture is unchanged.
This commit is contained in:
1 parent
22eb9cba0d
commit
cfffb693c3
9 files changed
+1718
-1
No files matched your search
@@ -76,6 +76,11 @@ const READ_TOOLS = [
|
||||
"list_metrics",
|
||||
"search",
|
||||
"fetch",
|
||||
// Phase 4 — deep-value reads.
|
||||
"get_correlation",
|
||||
"compare_metric",
|
||||
"get_metric_baseline",
|
||||
"detect_changepoints",
|
||||
].sort();
|
||||
|
||||
/** A valid, narrow-scope (`health:read`) token resolution. */
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/doctor-report-data", () => ({
|
||||
collectDoctorReportData: vi.fn(),
|
||||
}));
|
||||
|
||||
import { MCP_PROMPTS, MCP_PROMPT_NAMES } from "../prompts";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
|
||||
const CTX: McpAuthContext = {
|
||||
userId: "user-1",
|
||||
tokenId: "token-1",
|
||||
scopes: ["health:read"],
|
||||
binding: "user-1:token-1",
|
||||
canRead: true,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
function prompt(name: string) {
|
||||
const def = MCP_PROMPTS.find((p) => p.name === name);
|
||||
if (!def) throw new Error(`prompt ${name} not registered`);
|
||||
return def;
|
||||
}
|
||||
|
||||
/** Extract the JSON data block the prompt embeds in its user message. */
|
||||
function parseData(text: string): Record<string, unknown> {
|
||||
const marker = "JSON):\n";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) throw new Error("no data block in prompt message");
|
||||
return JSON.parse(text.slice(idx + marker.length));
|
||||
}
|
||||
|
||||
/** A minimal but representative doctor-report payload. */
|
||||
function reportFixture(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
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,
|
||||
},
|
||||
practiceName: null,
|
||||
measurements: {},
|
||||
stats: {
|
||||
WEIGHT: { avg: 80.4, min: 79, max: 82, count: 30, latest: 80 },
|
||||
RESTING_HEART_RATE: { avg: 58, min: 52, max: 66, count: 45, latest: 60 },
|
||||
},
|
||||
glucoseStats: {},
|
||||
glucoseRanges: {},
|
||||
glucoseUnit: "mgdl",
|
||||
glucoseClinical: { stillLearning: true, stillLearningReason: "thin" },
|
||||
bmi: 24.1,
|
||||
compliance: {
|
||||
Ramipril: { total: 90, taken: 86, skipped: 0, missed: 4 },
|
||||
},
|
||||
medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }],
|
||||
mood: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("MCP prompt registry — surface", () => {
|
||||
it("registers the doctor_visit_summary prompt with an args schema", () => {
|
||||
expect([...MCP_PROMPT_NAMES]).toContain("doctor_visit_summary");
|
||||
const def = prompt("doctor_visit_summary");
|
||||
expect(def.title).toBeTruthy();
|
||||
expect(def.description).toBeTruthy();
|
||||
expect(def.argsShape).toHaveProperty("window");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doctor_visit_summary — grounding + structure", () => {
|
||||
it("assembles a structured summary from the real doctor-report data", async () => {
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue(
|
||||
reportFixture() as never,
|
||||
);
|
||||
|
||||
const res = await prompt("doctor_visit_summary").run(CTX, {
|
||||
window: "last90days",
|
||||
});
|
||||
|
||||
// One user message carrying framing + data.
|
||||
expect(res.messages).toHaveLength(1);
|
||||
expect(res.messages[0].role).toBe("user");
|
||||
const text = res.messages[0].content.text;
|
||||
|
||||
// The window threads through to the data path (trailing 90 days).
|
||||
expect(collectDoctorReportData).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({ days: 90 }),
|
||||
);
|
||||
|
||||
const data = parseData(text);
|
||||
const vitals = data.vitals as Array<Record<string, unknown>>;
|
||||
const weight = vitals.find((v) => v.metric === "WEIGHT")!;
|
||||
const rhr = vitals.find((v) => v.metric === "RESTING_HEART_RATE")!;
|
||||
|
||||
// Units + reference bands ride along, server-side.
|
||||
expect(weight.unit).toBe("kg");
|
||||
expect(weight.referenceBand).toBeNull(); // no universal weight band
|
||||
expect(rhr.unit).toBe("bpm");
|
||||
expect(rhr.referenceBand).toEqual({ low: 50, high: 100 });
|
||||
|
||||
// Values are the server-computed ones — no fabrication.
|
||||
expect(weight.latest).toBe(80);
|
||||
expect(rhr.avg).toBe(58);
|
||||
|
||||
// Compliance is the ledger-derived adherence (86/90 → 96 %).
|
||||
const compliance = data.compliance as Array<Record<string, unknown>>;
|
||||
expect(compliance[0].adherencePct).toBe(96);
|
||||
});
|
||||
|
||||
it("injects the grounding framing — no diagnosis, no invented values", async () => {
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue(
|
||||
reportFixture() as never,
|
||||
);
|
||||
const res = await prompt("doctor_visit_summary").run(CTX, {});
|
||||
const text = res.messages[0].content.text;
|
||||
expect(text).toMatch(/server-side/i);
|
||||
expect(text).toMatch(/do not.*diagnos/i);
|
||||
expect(text).toMatch(/do not (invent|estimate|infer)/i);
|
||||
});
|
||||
|
||||
it("omits the glucose panel while still learning and mood when absent", async () => {
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue(
|
||||
reportFixture() as never,
|
||||
);
|
||||
const data = parseData(
|
||||
(await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content
|
||||
.text,
|
||||
);
|
||||
expect(data).not.toHaveProperty("glucosePanel"); // stillLearning → withheld
|
||||
expect(data).not.toHaveProperty("mood"); // mood null
|
||||
});
|
||||
|
||||
it("includes labs with reference ranges and caps the list (token budget)", async () => {
|
||||
const labResults = Array.from({ length: 60 }, (_, i) => ({
|
||||
panel: null,
|
||||
analyte: `Analyte-${i}`,
|
||||
value: i,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: 0,
|
||||
referenceHigh: 100,
|
||||
takenAt: "2026-06-01T00:00:00.000Z",
|
||||
count: 1,
|
||||
}));
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue(
|
||||
reportFixture({ labResults }) as never,
|
||||
);
|
||||
const data = parseData(
|
||||
(await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content
|
||||
.text,
|
||||
);
|
||||
const labs = data.labs as Array<Record<string, unknown>>;
|
||||
expect(labs.length).toBe(50); // capped from 60
|
||||
expect(labs[0]).toHaveProperty("referenceHigh", 100);
|
||||
});
|
||||
|
||||
it("does not leak patient name into the summary", async () => {
|
||||
vi.mocked(collectDoctorReportData).mockResolvedValue(
|
||||
reportFixture() as never,
|
||||
);
|
||||
const data = parseData(
|
||||
(await prompt("doctor_visit_summary").run(CTX, {})).messages[0].content
|
||||
.text,
|
||||
);
|
||||
const patient = data.patient as Record<string, unknown>;
|
||||
expect(patient).not.toHaveProperty("fullName");
|
||||
expect(patient).not.toHaveProperty("username");
|
||||
expect(patient.dateOfBirth).toBe("1990-01-01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// --- Engine mocks hoisted before importing the module under test. ---
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
|
||||
readCoachCorrelations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/insights/derived/coach-read", () => ({
|
||||
buildCoachReadStrip: vi.fn(),
|
||||
}));
|
||||
// Keep the real `aggregateWmyBuckets` (pure linear composition); stub only the
|
||||
// DB-backed reader so the rich reads compute over a synthetic bucket sequence.
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/rollups/measurement-read-wmy")>();
|
||||
return { ...actual, readBestGranularityRollups: vi.fn() };
|
||||
});
|
||||
|
||||
import {
|
||||
getCorrelation,
|
||||
compareMetric,
|
||||
getMetricBaseline,
|
||||
detectChangepoints,
|
||||
resolveRichMetric,
|
||||
} from "../rich-reads";
|
||||
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
|
||||
const USER = "user-1";
|
||||
|
||||
/** Build a rollup bucket row with just the fields the rich reads consume. */
|
||||
function row(bucketStart: string, mean: number) {
|
||||
return {
|
||||
bucketStart: new Date(bucketStart),
|
||||
count: 1,
|
||||
mean,
|
||||
sd: null,
|
||||
slope: null,
|
||||
r2: null,
|
||||
sumValue: null,
|
||||
minValue: mean,
|
||||
maxValue: mean,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// ── metric resolution ────────────────────────────────────────────────
|
||||
describe("resolveRichMetric", () => {
|
||||
it("resolves a friendly alias to the registry type + unit + band", () => {
|
||||
const m = resolveRichMetric("resting_hr");
|
||||
expect(m?.measurementType).toBe("RESTING_HEART_RATE");
|
||||
expect(m?.unit).toBe("bpm");
|
||||
expect(m?.band).toEqual({ low: 50, high: 100 });
|
||||
});
|
||||
|
||||
it("resolves the headline supplement metrics (weight has no universal band)", () => {
|
||||
const weight = resolveRichMetric("weight");
|
||||
expect(weight?.measurementType).toBe("WEIGHT");
|
||||
expect(weight?.unit).toBe("kg");
|
||||
expect(weight?.band).toBeNull();
|
||||
expect(resolveRichMetric("pulse")?.band).toEqual({ low: 60, high: 100 });
|
||||
});
|
||||
|
||||
it("matches on display name", () => {
|
||||
expect(resolveRichMetric("heart-rate variability")?.measurementType).toBe(
|
||||
"HEART_RATE_VARIABILITY",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for an unknown metric (never invents a series)", () => {
|
||||
expect(resolveRichMetric("bananas")).toBeNull();
|
||||
expect(resolveRichMetric("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── 1. get_correlation ───────────────────────────────────────────────
|
||||
describe("get_correlation", () => {
|
||||
const drivers = [
|
||||
{
|
||||
behaviour: "sleep duration",
|
||||
outcome: "resting heart rate",
|
||||
direction: "lower" as const,
|
||||
lagDays: 1,
|
||||
n: 60,
|
||||
r: -0.42,
|
||||
note: "Nights with more sleep tend to precede a lower next-day resting heart rate. Descriptive, not causal.",
|
||||
},
|
||||
{
|
||||
behaviour: "steps",
|
||||
outcome: "weight",
|
||||
direction: "lower" as const,
|
||||
lagDays: 1,
|
||||
n: 40,
|
||||
r: -0.2,
|
||||
note: "Descriptive only.",
|
||||
},
|
||||
];
|
||||
|
||||
it("returns the matched FDR-controlled pair with a descriptive (non-causal) note", async () => {
|
||||
vi.mocked(readCoachCorrelations).mockResolvedValue({
|
||||
present: true,
|
||||
drivers,
|
||||
pairsTested: 24,
|
||||
windowDays: 180,
|
||||
});
|
||||
const res = await getCorrelation(USER, {
|
||||
metricA: "sleep",
|
||||
metricB: "resting heart rate",
|
||||
});
|
||||
expect(res.present).toBe(true);
|
||||
expect(res.pair?.behaviour).toBe("sleep duration");
|
||||
expect(res.pair?.outcome).toBe("resting heart rate");
|
||||
expect(res.pair?.r).toBe(-0.42);
|
||||
expect(res.association).toBe("descriptive");
|
||||
expect(res.windowDays).toBe(180);
|
||||
});
|
||||
|
||||
it("honest-null when the pair has no surviving association", async () => {
|
||||
vi.mocked(readCoachCorrelations).mockResolvedValue({
|
||||
present: true,
|
||||
drivers,
|
||||
pairsTested: 24,
|
||||
windowDays: 180,
|
||||
});
|
||||
const res = await getCorrelation(USER, {
|
||||
metricA: "glucose",
|
||||
metricB: "hrv",
|
||||
});
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("no_significant_pattern_for_pair");
|
||||
expect(res.pair).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honest-null when the engine surfaces nothing (sparse data)", async () => {
|
||||
vi.mocked(readCoachCorrelations).mockResolvedValue({
|
||||
present: false,
|
||||
reason: "no_significant_pattern",
|
||||
});
|
||||
const res = await getCorrelation(USER, {
|
||||
metricA: "sleep",
|
||||
metricB: "weight",
|
||||
});
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("no_significant_pattern");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 2. compare_metric ────────────────────────────────────────────────
|
||||
describe("compare_metric", () => {
|
||||
it("compares two metrics over the same window and computes a delta when units match", async () => {
|
||||
vi.mocked(readBestGranularityRollups)
|
||||
.mockResolvedValueOnce({
|
||||
granularity: "DAY",
|
||||
rows: [row("2026-06-01", 60), row("2026-06-02", 62)],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
granularity: "DAY",
|
||||
rows: [row("2026-06-01", 70), row("2026-06-02", 72)],
|
||||
});
|
||||
const res = await compareMetric(USER, {
|
||||
metric: "resting_hr",
|
||||
metricB: "pulse",
|
||||
window: "last30days",
|
||||
});
|
||||
expect(res.present).toBe(true);
|
||||
expect(res.mode).toBe("metric_vs_metric");
|
||||
expect(res.a?.mean).toBe(61);
|
||||
expect(res.b?.mean).toBe(71);
|
||||
// Both bpm → delta computed.
|
||||
expect(res.delta?.mean).toBe(10);
|
||||
expect(res.a?.unit).toBe("bpm");
|
||||
});
|
||||
|
||||
it("compares one metric across two trailing windows", async () => {
|
||||
vi.mocked(readBestGranularityRollups)
|
||||
.mockResolvedValueOnce({
|
||||
granularity: "DAY",
|
||||
rows: [row("2026-06-01", 80), row("2026-06-02", 80)],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
granularity: "WEEK",
|
||||
rows: [row("2026-04-01", 82), row("2026-04-08", 84)],
|
||||
});
|
||||
const res = await compareMetric(USER, {
|
||||
metric: "weight",
|
||||
window: "last30days",
|
||||
windowB: "last90days",
|
||||
});
|
||||
expect(res.present).toBe(true);
|
||||
expect(res.mode).toBe("window_vs_window");
|
||||
expect(res.delta?.mean).toBe(3); // 83 - 80
|
||||
});
|
||||
|
||||
it("asks for a second metric or window when neither is given", async () => {
|
||||
const res = await compareMetric(USER, { metric: "weight" });
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("specify_metricB_or_windowB");
|
||||
});
|
||||
|
||||
it("honest-null on unknown metric", async () => {
|
||||
const res = await compareMetric(USER, {
|
||||
metric: "bananas",
|
||||
metricB: "weight",
|
||||
});
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("unknown_metric");
|
||||
});
|
||||
|
||||
it("present:false when a side has no data", async () => {
|
||||
vi.mocked(readBestGranularityRollups)
|
||||
.mockResolvedValueOnce({
|
||||
granularity: "DAY",
|
||||
rows: [row("2026-06-01", 80)],
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
const res = await compareMetric(USER, {
|
||||
metric: "weight",
|
||||
window: "last30days",
|
||||
windowB: "last90days",
|
||||
});
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("no_data");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 3. get_metric_baseline ───────────────────────────────────────────
|
||||
describe("get_metric_baseline", () => {
|
||||
it("returns the personal band + today's placement + reference band", async () => {
|
||||
vi.mocked(buildCoachReadStrip).mockResolvedValue({
|
||||
baseline: {
|
||||
low: 55,
|
||||
high: 65,
|
||||
latest: 70,
|
||||
placement: "above",
|
||||
sampleDays: 30,
|
||||
},
|
||||
learning: false,
|
||||
driver: {
|
||||
note: "Descriptive only.",
|
||||
behaviour: "sleep duration",
|
||||
outcome: "resting heart rate",
|
||||
},
|
||||
});
|
||||
const res = await getMetricBaseline(USER, { metric: "resting_hr" });
|
||||
expect(res.present).toBe(true);
|
||||
expect(res.baseline).toEqual({ low: 55, high: 65, sampleDays: 30 });
|
||||
expect(res.latest).toBe(70);
|
||||
expect(res.placement).toBe("above");
|
||||
expect(res.unit).toBe("bpm");
|
||||
expect(res.referenceBand).toEqual({ low: 50, high: 100 });
|
||||
});
|
||||
|
||||
it("honest-null below the learning floor (never a fabricated range)", async () => {
|
||||
vi.mocked(buildCoachReadStrip).mockResolvedValue({
|
||||
baseline: null,
|
||||
learning: true,
|
||||
driver: null,
|
||||
});
|
||||
const res = await getMetricBaseline(USER, { metric: "weight" });
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("insufficient_history");
|
||||
// The personal band is absent — no invented numbers.
|
||||
expect(res.baseline).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honest-null on unknown metric", async () => {
|
||||
const res = await getMetricBaseline(USER, { metric: "bananas" });
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("unknown_metric");
|
||||
});
|
||||
});
|
||||
|
||||
// ── 4. detect_changepoints ───────────────────────────────────────────
|
||||
describe("detect_changepoints", () => {
|
||||
it("detects a clear level shift over the rollup buckets", async () => {
|
||||
// Twelve buckets: ~80 for six, then a clean jump to ~90 for six.
|
||||
const rows = [
|
||||
row("2026-03-01", 80),
|
||||
row("2026-03-02", 81),
|
||||
row("2026-03-03", 79),
|
||||
row("2026-03-04", 80),
|
||||
row("2026-03-05", 81),
|
||||
row("2026-03-06", 80),
|
||||
row("2026-03-07", 90),
|
||||
row("2026-03-08", 91),
|
||||
row("2026-03-09", 89),
|
||||
row("2026-03-10", 90),
|
||||
row("2026-03-11", 91),
|
||||
row("2026-03-12", 90),
|
||||
];
|
||||
vi.mocked(readBestGranularityRollups).mockResolvedValue({
|
||||
granularity: "DAY",
|
||||
rows,
|
||||
});
|
||||
const res = await detectChangepoints(USER, {
|
||||
metric: "weight",
|
||||
window: "last90days",
|
||||
});
|
||||
expect(res.present).toBe(true);
|
||||
expect(res.changepoints?.length).toBeGreaterThanOrEqual(1);
|
||||
const cp = res.changepoints![0];
|
||||
expect(cp.direction).toBe("increase");
|
||||
expect(cp.at).toBe(new Date("2026-03-07").toISOString());
|
||||
expect(res.granularity).toBe("DAY");
|
||||
});
|
||||
|
||||
it("honest-null on a flat (noise-only) series", async () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) =>
|
||||
row(`2026-03-${String(i + 1).padStart(2, "0")}`, 80 + (i % 2)),
|
||||
);
|
||||
vi.mocked(readBestGranularityRollups).mockResolvedValue({
|
||||
granularity: "DAY",
|
||||
rows,
|
||||
});
|
||||
const res = await detectChangepoints(USER, { metric: "weight" });
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("no_changepoint");
|
||||
expect(res.bucketsAnalysed).toBe(12);
|
||||
});
|
||||
|
||||
it("honest-null when too few buckets exist", async () => {
|
||||
vi.mocked(readBestGranularityRollups).mockResolvedValue({
|
||||
granularity: "DAY",
|
||||
rows: [row("2026-03-01", 80), row("2026-03-02", 81)],
|
||||
});
|
||||
const res = await detectChangepoints(USER, { metric: "weight" });
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("insufficient_data");
|
||||
});
|
||||
|
||||
it("honest-null on unknown metric", async () => {
|
||||
const res = await detectChangepoints(USER, { metric: "bananas" });
|
||||
expect(res.present).toBe(false);
|
||||
expect(res.reason).toBe("unknown_metric");
|
||||
});
|
||||
});
|
||||
|
||||
// ── grounding posture ────────────────────────────────────────────────
|
||||
describe("grounding / no fabrication", () => {
|
||||
it("no rich read returns a verdict / diagnosis / advice field", async () => {
|
||||
vi.mocked(readCoachCorrelations).mockResolvedValue({ present: false });
|
||||
vi.mocked(buildCoachReadStrip).mockResolvedValue({
|
||||
baseline: null,
|
||||
learning: true,
|
||||
driver: null,
|
||||
});
|
||||
vi.mocked(readBestGranularityRollups).mockResolvedValue(null);
|
||||
|
||||
const results = [
|
||||
await getCorrelation(USER, { metricA: "sleep", metricB: "weight" }),
|
||||
await compareMetric(USER, { metric: "weight", windowB: "last90days" }),
|
||||
await getMetricBaseline(USER, { metric: "weight" }),
|
||||
await detectChangepoints(USER, { metric: "weight" }),
|
||||
];
|
||||
for (const res of results) {
|
||||
const keys = Object.keys(res);
|
||||
expect(keys).not.toContain("verdict");
|
||||
expect(keys).not.toContain("diagnosis");
|
||||
expect(keys).not.toContain("advice");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,15 @@ vi.mock("@/lib/db", () => ({
|
||||
labResult: { findMany: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
// Phase 4 — the deep-value reads delegate to the rich-reads engines; stub them
|
||||
// so the registry-wide loops (surface / annotation / no-verdict) never reach a
|
||||
// real engine. Their own logic is covered in `rich-reads.test.ts`.
|
||||
vi.mock("../rich-reads", () => ({
|
||||
getCorrelation: vi.fn(async () => ({ present: true })),
|
||||
compareMetric: vi.fn(async () => ({ present: true })),
|
||||
getMetricBaseline: vi.fn(async () => ({ present: true })),
|
||||
detectChangepoints: vi.fn(async () => ({ present: true })),
|
||||
}));
|
||||
|
||||
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
@@ -47,7 +56,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("MCP tool registry — surface", () => {
|
||||
it("registers exactly the Phase-1 read tools", () => {
|
||||
it("registers exactly the read tools (Phase 1 + Phase 4)", () => {
|
||||
expect([...MCP_TOOL_NAMES].sort()).toEqual(
|
||||
[
|
||||
"get_correlations",
|
||||
@@ -58,6 +67,11 @@ describe("MCP tool registry — surface", () => {
|
||||
// v1.22.0 — the ChatGPT default-mode retrieval pair.
|
||||
"search",
|
||||
"fetch",
|
||||
// Phase 4 — deep-value reads.
|
||||
"get_correlation",
|
||||
"compare_metric",
|
||||
"get_metric_baseline",
|
||||
"detect_changepoints",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,21 @@ export {
|
||||
MCP_RESOURCE_URIS,
|
||||
type McpResourceDefinition,
|
||||
} from "./resources";
|
||||
export {
|
||||
MCP_PROMPTS,
|
||||
MCP_PROMPT_NAMES,
|
||||
type McpPromptDefinition,
|
||||
type McpPromptResult,
|
||||
type McpPromptMessage,
|
||||
} from "./prompts";
|
||||
export {
|
||||
getCorrelation,
|
||||
compareMetric,
|
||||
getMetricBaseline,
|
||||
detectChangepoints,
|
||||
resolveRichMetric,
|
||||
type RichMetric,
|
||||
} from "./rich-reads";
|
||||
export {
|
||||
SCOPE_HEALTH_READ,
|
||||
SCOPE_HEALTH_WRITE,
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Phase 4 — MCP prompts (user-controlled templated workflows).
|
||||
*
|
||||
* Prompts are the slash-command surface a host exposes to the user. Unlike
|
||||
* tools (model-controlled), a prompt is picked by the user and returns a set of
|
||||
* messages pre-loaded with REAL, server-retrieved data plus the safety framing
|
||||
* injected once, centrally (catalogue §5 #2, ADR-004).
|
||||
*
|
||||
* `doctor_visit_summary` is the showcase capability: it assembles a grounded,
|
||||
* structured visit-prep summary from the SAME doctor-report data path
|
||||
* (`collectDoctorReportData`) that backs the PDF the user already trusts — so
|
||||
* the numbers match the export byte-for-byte. It carries the identical grounding
|
||||
* discipline as the Coach:
|
||||
* - only values the server computed (no fabricated readings);
|
||||
* - reference / clinical bands are server-side, never invented by the model;
|
||||
* - DATA + CONTEXT only — never a diagnosis, verdict, or treatment change.
|
||||
*
|
||||
* Token budget (R-DEL-2): the prompt SUMMARISES (latest + aggregate per metric,
|
||||
* latest reading per analyte) — it never dumps the full per-reading history.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
type DoctorReportData,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
|
||||
import { coachScopeWindowSchema } from "@/lib/ai/coach/types";
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Trailing-day count per window, capped at the doctor-report 365-day ceiling. */
|
||||
const WINDOW_DAYS: Record<CoachScopeWindow, number> = {
|
||||
last7days: 7,
|
||||
last30days: 30,
|
||||
last90days: 90,
|
||||
lastYear: 365,
|
||||
allTime: 365,
|
||||
};
|
||||
|
||||
/** A returned prompt message — mirrors the SDK `GetPromptResult` shape. */
|
||||
export interface McpPromptMessage {
|
||||
role: "user" | "assistant";
|
||||
content: { type: "text"; text: string };
|
||||
}
|
||||
|
||||
export interface McpPromptResult {
|
||||
description?: string;
|
||||
messages: McpPromptMessage[];
|
||||
}
|
||||
|
||||
/** A registry entry — transport-agnostic, mirroring `McpToolDefinition`. */
|
||||
export interface McpPromptDefinition {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** zod/v4 raw shape handed to the SDK's `registerPrompt` for arg validation. */
|
||||
argsShape: z.ZodRawShape;
|
||||
run: (
|
||||
ctx: McpAuthContext,
|
||||
args: Record<string, unknown>,
|
||||
) => 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.
|
||||
*/
|
||||
const VISIT_FRAMING = [
|
||||
"You are preparing a concise, factual summary to help the user get ready for a doctor's visit.",
|
||||
"Use ONLY the structured data below — every value, unit, and reference band is computed server-side from the user's own records.",
|
||||
"Do NOT invent, estimate, or infer any reading that is not present. Do NOT provide a diagnosis, clinical verdict, risk score, or treatment/medication change — those are the clinician's role.",
|
||||
"Organise the data into clear sections (vitals, medications & adherence, labs, notable changes). Where a value sits outside its reference band, state the value and band factually; do not interpret it.",
|
||||
"If a section is empty, say it was not recorded in this period rather than guessing.",
|
||||
].join(" ");
|
||||
|
||||
export const MCP_PROMPTS: McpPromptDefinition[] = [
|
||||
{
|
||||
name: "doctor_visit_summary",
|
||||
title: "Doctor visit summary",
|
||||
description:
|
||||
"Assemble a structured, grounded visit-prep summary from the user's own records over a window — vitals (with units + reference bands), medications & adherence, labs (with reference ranges), and notable context. Reuses the doctor-report data path so the numbers match the exported PDF. Data + context only: no diagnosis, no verdict.",
|
||||
argsShape: { window: coachScopeWindowSchema.optional() },
|
||||
async run(ctx, args) {
|
||||
const window = (
|
||||
typeof args.window === "string" ? args.window : "last90days"
|
||||
) as CoachScopeWindow;
|
||||
const days = WINDOW_DAYS[window] ?? 90;
|
||||
const end = new Date();
|
||||
const start = new Date(end.getTime() - days * MS_PER_DAY);
|
||||
|
||||
const data = await collectDoctorReportData(ctx.userId, {
|
||||
start,
|
||||
end,
|
||||
days,
|
||||
});
|
||||
const summary = summariseForVisit(data);
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.prompt.invoked" },
|
||||
meta: {
|
||||
prompt: "doctor_visit_summary",
|
||||
days,
|
||||
vitals: Array.isArray(summary.vitals) ? summary.vitals.length : 0,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
description: `Visit-prep summary over the last ${days} days, grounded in your own records.`,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `${VISIT_FRAMING}\n\nDATA (server-authoritative, JSON):\n${JSON.stringify(
|
||||
summary,
|
||||
)}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Stable list of the registered prompt names. */
|
||||
export const MCP_PROMPT_NAMES: readonly string[] = MCP_PROMPTS.map(
|
||||
(p) => p.name,
|
||||
);
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Phase 4 — deep-value MCP read tools (engine glue).
|
||||
*
|
||||
* Four "bring data into relation" reads, each a THIN re-export of an existing
|
||||
* server-authoritative engine — no new statistics, no new business logic
|
||||
* (REQ-WONT-2 / catalogue §5):
|
||||
*
|
||||
* 1. get_correlation — one named driver pair out of the FDR-controlled,
|
||||
* lag-aware discovery (`readCoachCorrelations`, the
|
||||
* same engine the Coach `get_correlations` tool and
|
||||
* the `/api/insights/correlations` route run).
|
||||
* 2. compare_metric — two trailing-window or two-metric rollup snapshots
|
||||
* side by side (`readBestGranularityRollups` +
|
||||
* `aggregateWmyBuckets`) with structured deltas.
|
||||
* 3. get_metric_baseline — the metric's personal usual range (median ± k·MAD)
|
||||
* + today's placement, via `buildCoachReadStrip`
|
||||
* (the same engine the metric-page "Coach read" strip
|
||||
* renders).
|
||||
* 4. detect_changepoints — level shifts in a metric over the rollup tier, via
|
||||
* a minimal in-repo CUSUM binary-segmentation over the
|
||||
* rollup bucket means (no heavy dependency, high
|
||||
* firing bar).
|
||||
*
|
||||
* Grounding contract (REQ-SEC-2/3/4, ADR-004): every read returns structured
|
||||
* values + units + reference bands + provenance and uses `{ present: false }`
|
||||
* for absence / insufficiency — never a silent zero, never a prose verdict or
|
||||
* diagnosis. Associations are described, never asserted as causal. `userId` is
|
||||
* the session-narrowed id passed by the caller, never a tool argument
|
||||
* (REQ-SEC-5). All reads are read-only.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
|
||||
import { buildCoachReadStrip } from "@/lib/insights/derived/coach-read";
|
||||
import {
|
||||
readBestGranularityRollups,
|
||||
aggregateWmyBuckets,
|
||||
} from "@/lib/rollups/measurement-read-wmy";
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
METRIC_STATUS_IDS,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
|
||||
/** Trailing-day count for each window the rich reads accept. */
|
||||
const WINDOW_DAYS: Record<CoachScopeWindow, number> = {
|
||||
last7days: 7,
|
||||
last30days: 30,
|
||||
last90days: 90,
|
||||
lastYear: 365,
|
||||
// The rollup tier caps "all time" timelines at one year elsewhere; keep the
|
||||
// rich reads bounded to the same ceiling so the token budget stays sane.
|
||||
allTime: 365,
|
||||
};
|
||||
|
||||
function windowToDays(window: CoachScopeWindow | undefined): number {
|
||||
return WINDOW_DAYS[window ?? "last30days"];
|
||||
}
|
||||
|
||||
// ── Metric resolution ────────────────────────────────────────────────
|
||||
//
|
||||
// A scalar metric the rich reads can place on a single rollup series, carrying
|
||||
// its canonical unit + population reference band (when a broadly-accepted one
|
||||
// exists). Most entries are derived straight from the single-source
|
||||
// `metric-status-registry`; a small explicit supplement covers the headline
|
||||
// specialised metrics (weight / pulse / BMI) the registry intentionally omits.
|
||||
|
||||
export interface RichMetric {
|
||||
/** The DB `MeasurementType` the rollup tier + baseline engine read against. */
|
||||
measurementType: MeasurementType;
|
||||
/** Human label for the resolved metric (provenance / narration). */
|
||||
label: string;
|
||||
/** Canonical storage unit. */
|
||||
unit: string;
|
||||
/** Population reference band, or `null` when no universal band exists. */
|
||||
band: { low: number; high: number } | null;
|
||||
}
|
||||
|
||||
/** Headline specialised metrics the status registry does not carry. */
|
||||
const SUPPLEMENT: Record<string, RichMetric> = {
|
||||
weight: {
|
||||
measurementType: "WEIGHT",
|
||||
label: "Weight",
|
||||
unit: "kg",
|
||||
// No universal healthy band — body-size dependent; defer to own baseline.
|
||||
band: null,
|
||||
},
|
||||
pulse: {
|
||||
measurementType: "PULSE",
|
||||
label: "Pulse",
|
||||
unit: "bpm",
|
||||
band: { low: 60, high: 100 },
|
||||
},
|
||||
bmi: {
|
||||
measurementType: "BODY_MASS_INDEX",
|
||||
label: "Body-mass index",
|
||||
unit: "kg/m²",
|
||||
band: { low: 18.5, high: 25 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Friendly aliases (Coach source slugs + natural phrasings) → registry id. */
|
||||
const ALIASES: Record<string, string> = {
|
||||
resting_hr: "RESTING_HEART_RATE",
|
||||
resting_heart_rate: "RESTING_HEART_RATE",
|
||||
hrv: "HEART_RATE_VARIABILITY",
|
||||
spo2: "OXYGEN_SATURATION",
|
||||
blood_oxygen: "OXYGEN_SATURATION",
|
||||
vo2_max: "VO2_MAX",
|
||||
vo2max: "VO2_MAX",
|
||||
steps: "STEPS",
|
||||
glucose: "BLOOD_GLUCOSE",
|
||||
blood_glucose: "BLOOD_GLUCOSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
sleep_duration: "SLEEP_DURATION",
|
||||
distance: "WALKING_RUNNING_DISTANCE",
|
||||
active_energy: "ACTIVE_ENERGY",
|
||||
body_temp: "BODY_TEMPERATURE",
|
||||
body_temperature: "BODY_TEMPERATURE",
|
||||
daylight: "TIME_IN_DAYLIGHT",
|
||||
respiratory_rate: "RESPIRATORY_RATE",
|
||||
walking_hr: "WALKING_HEART_RATE_AVERAGE",
|
||||
};
|
||||
|
||||
function fromRegistry(id: string): RichMetric | null {
|
||||
const meta = getMetricStatusMeta(id);
|
||||
if (!meta) return null;
|
||||
return {
|
||||
measurementType: meta.measurementType,
|
||||
label: meta.displayName,
|
||||
unit: meta.unit,
|
||||
band: meta.normalRange
|
||||
? { low: meta.normalRange.low, high: meta.normalRange.high }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a free-text metric name to a single scalar series. Forgiving for an
|
||||
* NL assistant (alias, exact id, display-name match) but closed — an
|
||||
* unresolved name returns `null` so the tool reports `{ present: false }`
|
||||
* rather than inventing a series. Multi-series metrics (blood pressure) are not
|
||||
* resolvable here by design; the prompt + `get_metric_series` cover BP.
|
||||
*/
|
||||
export function resolveRichMetric(input: string): RichMetric | null {
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
|
||||
|
||||
// 1. explicit supplement (weight / pulse / bmi).
|
||||
if (SUPPLEMENT[key]) return SUPPLEMENT[key];
|
||||
|
||||
// 2. friendly alias → registry id.
|
||||
const aliased = ALIASES[key];
|
||||
if (aliased) return fromRegistry(aliased);
|
||||
|
||||
// 3. exact registry id (case-insensitive).
|
||||
const upper = key.toUpperCase();
|
||||
if ((METRIC_STATUS_IDS as readonly string[]).includes(upper)) {
|
||||
const direct = fromRegistry(upper);
|
||||
if (direct) return direct;
|
||||
}
|
||||
|
||||
// 4. display-name match (e.g. "heart-rate variability").
|
||||
const needle = raw.toLowerCase();
|
||||
for (const id of METRIC_STATUS_IDS) {
|
||||
const meta = getMetricStatusMeta(id);
|
||||
if (!meta) continue;
|
||||
const name = meta.displayName.toLowerCase();
|
||||
if (name === needle || name.includes(needle) || needle.includes(name)) {
|
||||
return fromRegistry(id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 1. get_correlation ───────────────────────────────────────────────
|
||||
|
||||
export interface CorrelationResult {
|
||||
present: boolean;
|
||||
reason?: string;
|
||||
/** The matched driver pair (descriptive, never causal). */
|
||||
pair?: {
|
||||
behaviour: string;
|
||||
outcome: string;
|
||||
direction: "higher" | "lower";
|
||||
lagDays: number;
|
||||
n: number;
|
||||
r: number;
|
||||
note: string;
|
||||
};
|
||||
/** Honest footer — pairs tested + window the discovery scanned. */
|
||||
pairsTested?: number;
|
||||
windowDays?: number;
|
||||
/** Constant marker so the model never re-frames the link as causal. */
|
||||
association?: "descriptive";
|
||||
}
|
||||
|
||||
function norm(label: string): string {
|
||||
return label.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** True when `a`/`b` (either order) match the driver's behaviour/outcome. */
|
||||
function pairMatches(
|
||||
behaviour: string,
|
||||
outcome: string,
|
||||
a: string,
|
||||
b: string,
|
||||
): boolean {
|
||||
const contains = (hay: string, needle: string) =>
|
||||
hay.includes(needle) || needle.includes(hay);
|
||||
return (
|
||||
(contains(behaviour, a) && contains(outcome, b)) ||
|
||||
(contains(behaviour, b) && contains(outcome, a))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the FDR-controlled, lag-aware association between two named metrics.
|
||||
* Pure re-export: runs the SAME discovery the Coach `get_correlations` tool and
|
||||
* the insight route run, then selects the surviving pair (strongest |r|) that
|
||||
* matches the requested two metrics — never re-computing a relationship the
|
||||
* engine did not surface. Honest `{ present: false }` when no surviving pair
|
||||
* matches (sparse data, or the link did not clear the engine's floors).
|
||||
*/
|
||||
export async function getCorrelation(
|
||||
userId: string,
|
||||
args: { metricA: string; metricB: string },
|
||||
): Promise<CorrelationResult> {
|
||||
const a = norm(args.metricA);
|
||||
const b = norm(args.metricB);
|
||||
const result = await readCoachCorrelations(userId);
|
||||
|
||||
if (!result.present || !result.drivers || result.drivers.length === 0) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_correlation", present: false },
|
||||
});
|
||||
return {
|
||||
present: false,
|
||||
reason: result.reason ?? "no_significant_pattern",
|
||||
pairsTested: result.pairsTested,
|
||||
windowDays: result.windowDays,
|
||||
};
|
||||
}
|
||||
|
||||
const matches = result.drivers.filter((d) =>
|
||||
pairMatches(norm(d.behaviour), norm(d.outcome), a, b),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_correlation", present: false },
|
||||
});
|
||||
return {
|
||||
present: false,
|
||||
reason: "no_significant_pattern_for_pair",
|
||||
pairsTested: result.pairsTested,
|
||||
windowDays: result.windowDays,
|
||||
};
|
||||
}
|
||||
|
||||
const best = matches.reduce((acc, cur) =>
|
||||
Math.abs(cur.r) > Math.abs(acc.r) ? cur : acc,
|
||||
);
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_correlation", present: true },
|
||||
});
|
||||
return {
|
||||
present: true,
|
||||
pair: {
|
||||
behaviour: best.behaviour,
|
||||
outcome: best.outcome,
|
||||
direction: best.direction,
|
||||
lagDays: best.lagDays,
|
||||
n: best.n,
|
||||
r: best.r,
|
||||
note: best.note,
|
||||
},
|
||||
pairsTested: result.pairsTested,
|
||||
windowDays: result.windowDays,
|
||||
association: "descriptive",
|
||||
};
|
||||
}
|
||||
|
||||
// ── 2. compare_metric ────────────────────────────────────────────────
|
||||
|
||||
interface MetricWindowSnapshot {
|
||||
label: string;
|
||||
unit: string;
|
||||
band: { low: number; high: number } | null;
|
||||
windowDays: number;
|
||||
granularity: string;
|
||||
count: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}
|
||||
|
||||
export interface CompareMetricResult {
|
||||
present: boolean;
|
||||
reason?: string;
|
||||
mode?: "metric_vs_metric" | "window_vs_window";
|
||||
a?: MetricWindowSnapshot;
|
||||
b?: MetricWindowSnapshot;
|
||||
/** Delta b − a, only when both sides share a unit; null otherwise. */
|
||||
delta?: { mean: number; pct: number | null } | null;
|
||||
}
|
||||
|
||||
async function snapshotMetricWindow(
|
||||
userId: string,
|
||||
metric: RichMetric,
|
||||
windowDays: number,
|
||||
): Promise<MetricWindowSnapshot | null> {
|
||||
const read = await readBestGranularityRollups(
|
||||
userId,
|
||||
metric.measurementType,
|
||||
windowDays,
|
||||
);
|
||||
if (!read || read.rows.length === 0) return null;
|
||||
const agg = aggregateWmyBuckets(read.rows);
|
||||
if (agg.count === 0) return null;
|
||||
return {
|
||||
label: metric.label,
|
||||
unit: metric.unit,
|
||||
band: metric.band,
|
||||
windowDays,
|
||||
granularity: read.granularity,
|
||||
count: agg.count,
|
||||
mean: agg.mean,
|
||||
min: agg.min,
|
||||
max: agg.max,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a metric against another metric (same trailing window) OR a single
|
||||
* metric across two trailing windows. Pure re-export of the WMY rollup reader +
|
||||
* the linear `aggregateWmyBuckets` composition — no new math. Windows are
|
||||
* trailing-to-now (e.g. last 30 days vs last 90 days); a delta is only computed
|
||||
* when both sides carry the same unit, so the result never compares unlike
|
||||
* scales. `{ present: false }` when neither side has data.
|
||||
*/
|
||||
export async function compareMetric(
|
||||
userId: string,
|
||||
args: {
|
||||
metric: string;
|
||||
metricB?: string;
|
||||
window?: CoachScopeWindow;
|
||||
windowB?: CoachScopeWindow;
|
||||
},
|
||||
): Promise<CompareMetricResult> {
|
||||
const metricA = resolveRichMetric(args.metric);
|
||||
if (!metricA) {
|
||||
return { present: false, reason: "unknown_metric" };
|
||||
}
|
||||
const daysA = windowToDays(args.window);
|
||||
|
||||
const annotateMiss = () =>
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "compare_metric", present: false },
|
||||
});
|
||||
|
||||
let mode: "metric_vs_metric" | "window_vs_window";
|
||||
let metricB: RichMetric;
|
||||
let daysB: number;
|
||||
|
||||
if (args.metricB) {
|
||||
const resolvedB = resolveRichMetric(args.metricB);
|
||||
if (!resolvedB) {
|
||||
annotateMiss();
|
||||
return { present: false, reason: "unknown_metric_b" };
|
||||
}
|
||||
mode = "metric_vs_metric";
|
||||
metricB = resolvedB;
|
||||
daysB = daysA;
|
||||
} else if (args.windowB) {
|
||||
mode = "window_vs_window";
|
||||
metricB = metricA;
|
||||
daysB = windowToDays(args.windowB);
|
||||
} else {
|
||||
annotateMiss();
|
||||
return { present: false, reason: "specify_metricB_or_windowB" };
|
||||
}
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
snapshotMetricWindow(userId, metricA, daysA),
|
||||
snapshotMetricWindow(userId, metricB, daysB),
|
||||
]);
|
||||
|
||||
if (!a || !b) {
|
||||
annotateMiss();
|
||||
return {
|
||||
present: false,
|
||||
reason: "no_data",
|
||||
mode,
|
||||
...(a ? { a } : {}),
|
||||
...(b ? { b } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
let delta: { mean: number; pct: number | null } | null = null;
|
||||
if (a.unit === b.unit && a.mean !== null && b.mean !== null) {
|
||||
const diff = b.mean - a.mean;
|
||||
delta = {
|
||||
mean: diff,
|
||||
pct: a.mean !== 0 ? (diff / Math.abs(a.mean)) * 100 : null,
|
||||
};
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "compare_metric", present: true },
|
||||
});
|
||||
return { present: true, mode, a, b, delta };
|
||||
}
|
||||
|
||||
// ── 3. get_metric_baseline ───────────────────────────────────────────
|
||||
|
||||
export interface MetricBaselineResult {
|
||||
present: boolean;
|
||||
reason?: string;
|
||||
metric?: string;
|
||||
unit?: string;
|
||||
/** The user's personal usual range (median ± k·MAD) + sample transparency. */
|
||||
baseline?: { low: number; high: number; sampleDays: number };
|
||||
/** Today's latest reading. */
|
||||
latest?: number;
|
||||
/** Where the latest reading sits relative to the personal band. */
|
||||
placement?: "within" | "above" | "below";
|
||||
/** Population reference band, or `null` when none exists for the metric. */
|
||||
referenceBand?: { low: number; high: number } | null;
|
||||
/** The single strongest lagged driver of this metric, or `null`. */
|
||||
driver?: { note: string; behaviour: string; outcome: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return where today's value sits against the user's own usual range. Pure
|
||||
* re-export of `buildCoachReadStrip` — the SAME median ± k·MAD baseline engine
|
||||
* (`computeVitalsBaseline`) + lagged-driver pick the metric page renders. Below
|
||||
* the engine's 7-day history floor the band is not asserted (`{ present: false,
|
||||
* reason: "insufficient_history" }`) — never a fabricated range. The population
|
||||
* reference band rides along as general context even on a miss.
|
||||
*/
|
||||
export async function getMetricBaseline(
|
||||
userId: string,
|
||||
args: { metric: string },
|
||||
): Promise<MetricBaselineResult> {
|
||||
const metric = resolveRichMetric(args.metric);
|
||||
if (!metric) {
|
||||
return { present: false, reason: "unknown_metric" };
|
||||
}
|
||||
|
||||
const strip = await buildCoachReadStrip(userId, metric.measurementType);
|
||||
|
||||
if (!strip.baseline) {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_metric_baseline", present: false },
|
||||
});
|
||||
return {
|
||||
present: false,
|
||||
reason: strip.learning ? "insufficient_history" : "no_data",
|
||||
metric: metric.label,
|
||||
unit: metric.unit,
|
||||
referenceBand: metric.band,
|
||||
};
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "get_metric_baseline", present: true },
|
||||
});
|
||||
return {
|
||||
present: true,
|
||||
metric: metric.label,
|
||||
unit: metric.unit,
|
||||
baseline: {
|
||||
low: strip.baseline.low,
|
||||
high: strip.baseline.high,
|
||||
sampleDays: strip.baseline.sampleDays,
|
||||
},
|
||||
latest: strip.baseline.latest,
|
||||
placement: strip.baseline.placement,
|
||||
referenceBand: metric.band,
|
||||
driver: strip.driver,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 4. detect_changepoints ───────────────────────────────────────────
|
||||
|
||||
export interface Changepoint {
|
||||
/** Bucket-start ISO timestamp of the first bucket of the new regime. */
|
||||
at: string;
|
||||
direction: "increase" | "decrease";
|
||||
beforeMean: number;
|
||||
afterMean: number;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface ChangepointsResult {
|
||||
present: boolean;
|
||||
reason?: string;
|
||||
metric?: string;
|
||||
unit?: string;
|
||||
granularity?: string;
|
||||
windowDays?: number;
|
||||
bucketsAnalysed?: number;
|
||||
changepoints?: Changepoint[];
|
||||
}
|
||||
|
||||
const MIN_SEGMENT = 5;
|
||||
/** Mean shift must clear this many series standard deviations to fire. */
|
||||
const SHIFT_SD_FLOOR = 1.5;
|
||||
const MAX_CHANGEPOINTS = 5;
|
||||
|
||||
function mean(xs: number[]): number {
|
||||
return xs.reduce((s, x) => s + x, 0) / xs.length;
|
||||
}
|
||||
|
||||
function stdDev(xs: number[]): number {
|
||||
if (xs.length < 2) return 0;
|
||||
const m = mean(xs);
|
||||
const variance = xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal CUSUM binary-segmentation changepoint detector over a 1-D series.
|
||||
*
|
||||
* For a segment, the cumulative sum of mean-centred deviations peaks at the most
|
||||
* likely split. The split fires ONLY when the resulting mean shift clears a
|
||||
* conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves
|
||||
* are at least MIN_SEGMENT long — a deliberately high bar so noise does not
|
||||
* register (honest-null over false positives). Accepted splits recurse on each
|
||||
* half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the
|
||||
* caller.
|
||||
*
|
||||
* Indices are into the original series. Pure / dependency-free.
|
||||
*/
|
||||
function cusumSegment(
|
||||
values: number[],
|
||||
lo: number,
|
||||
hi: number,
|
||||
out: Array<{ index: number; beforeMean: number; afterMean: number }>,
|
||||
): void {
|
||||
const n = hi - lo;
|
||||
if (n < 2 * MIN_SEGMENT) return;
|
||||
|
||||
const segment = values.slice(lo, hi);
|
||||
const segMean = mean(segment);
|
||||
const sd = stdDev(segment);
|
||||
if (sd <= 0) return;
|
||||
|
||||
// Cumulative sum of mean-centred deviations; track the extreme position.
|
||||
let cusum = 0;
|
||||
let maxAbs = 0;
|
||||
let splitRel = -1;
|
||||
for (let i = 0; i < n; i++) {
|
||||
cusum += segment[i] - segMean;
|
||||
if (Math.abs(cusum) > maxAbs) {
|
||||
maxAbs = Math.abs(cusum);
|
||||
// The new regime starts at the bucket AFTER the accumulation peak.
|
||||
splitRel = i + 1;
|
||||
}
|
||||
}
|
||||
if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return;
|
||||
|
||||
const before = segment.slice(0, splitRel);
|
||||
const after = segment.slice(splitRel);
|
||||
const beforeMean = mean(before);
|
||||
const afterMean = mean(after);
|
||||
if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return;
|
||||
|
||||
out.push({ index: lo + splitRel, beforeMean, afterMean });
|
||||
cusumSegment(values, lo, lo + splitRel, out);
|
||||
cusumSegment(values, lo + splitRel, hi, out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface level shifts in a metric over the rollup tier. Reads the trailing
|
||||
* window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days,
|
||||
* coarser for longer windows; the resolved granularity is reported) and runs
|
||||
* the minimal CUSUM above over the bucket means. High firing bar — returns
|
||||
* `{ present: false }` when too few buckets exist or no shift clears the floor.
|
||||
* Re-uses the rollup tier's already-computed bucket means; adds no new
|
||||
* persisted analytics.
|
||||
*/
|
||||
export async function detectChangepoints(
|
||||
userId: string,
|
||||
args: { metric: string; window?: CoachScopeWindow },
|
||||
): Promise<ChangepointsResult> {
|
||||
const metric = resolveRichMetric(args.metric);
|
||||
if (!metric) {
|
||||
return { present: false, reason: "unknown_metric" };
|
||||
}
|
||||
const windowDays = windowToDays(args.window ?? "last90days");
|
||||
|
||||
const read = await readBestGranularityRollups(
|
||||
userId,
|
||||
metric.measurementType,
|
||||
windowDays,
|
||||
);
|
||||
|
||||
const miss = (reason: string): ChangepointsResult => {
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "detect_changepoints", present: false },
|
||||
});
|
||||
return {
|
||||
present: false,
|
||||
reason,
|
||||
metric: metric.label,
|
||||
unit: metric.unit,
|
||||
windowDays,
|
||||
};
|
||||
};
|
||||
|
||||
if (!read || read.rows.length < 2 * MIN_SEGMENT) {
|
||||
return miss("insufficient_data");
|
||||
}
|
||||
|
||||
const values = read.rows.map((r) => r.mean);
|
||||
const found: Array<{ index: number; beforeMean: number; afterMean: number }> =
|
||||
[];
|
||||
cusumSegment(values, 0, values.length, found);
|
||||
|
||||
if (found.length === 0) {
|
||||
return {
|
||||
...miss("no_changepoint"),
|
||||
granularity: read.granularity,
|
||||
bucketsAnalysed: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
const changepoints: Changepoint[] = found
|
||||
.sort(
|
||||
(x, y) =>
|
||||
Math.abs(y.afterMean - y.beforeMean) -
|
||||
Math.abs(x.afterMean - x.beforeMean),
|
||||
)
|
||||
.slice(0, MAX_CHANGEPOINTS)
|
||||
.map((cp) => ({
|
||||
at: read.rows[cp.index].bucketStart.toISOString(),
|
||||
direction:
|
||||
cp.afterMean >= cp.beforeMean
|
||||
? ("increase" as const)
|
||||
: ("decrease" as const),
|
||||
beforeMean: cp.beforeMean,
|
||||
afterMean: cp.afterMean,
|
||||
delta: cp.afterMean - cp.beforeMean,
|
||||
}))
|
||||
.sort((x, y) => (x.at < y.at ? -1 : 1));
|
||||
|
||||
annotate({
|
||||
action: { name: "mcp.tool.invoked" },
|
||||
meta: { tool: "detect_changepoints", present: true },
|
||||
});
|
||||
return {
|
||||
present: true,
|
||||
metric: metric.label,
|
||||
unit: metric.unit,
|
||||
granularity: read.granularity,
|
||||
windowDays,
|
||||
bucketsAnalysed: values.length,
|
||||
changepoints,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
import { MCP_TOOLS } from "./tools";
|
||||
import { MCP_RESOURCES } from "./resources";
|
||||
import { MCP_PROMPTS } from "./prompts";
|
||||
|
||||
export const MCP_SERVER_NAME = "healthlog";
|
||||
export const MCP_SERVER_VERSION = "1.0.0";
|
||||
@@ -64,6 +65,29 @@ export function createMcpServer(ctx: McpAuthContext): McpServer {
|
||||
);
|
||||
}
|
||||
|
||||
for (const prompt of MCP_PROMPTS) {
|
||||
server.registerPrompt(
|
||||
prompt.name,
|
||||
{
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
argsSchema: prompt.argsShape,
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
// The prompt assembles REAL, server-retrieved data + the central
|
||||
// grounding framing; the result is a set of messages the host inserts
|
||||
// into the conversation (ADR-004). Read-only, like every Phase-4
|
||||
// surface — a prompt never mutates. Returned as a fresh literal so it
|
||||
// conforms to the SDK's `GetPromptResult` (index-signatured) shape.
|
||||
const result = await prompt.run(ctx, args ?? {});
|
||||
return {
|
||||
messages: result.messages,
|
||||
...(result.description ? { description: result.description } : {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const resource of MCP_RESOURCES) {
|
||||
server.registerResource(
|
||||
resource.name,
|
||||
|
||||
@@ -28,6 +28,12 @@ import {
|
||||
coachScopeWindowSchema,
|
||||
} from "@/lib/ai/coach/types";
|
||||
import { resolveBaseOrigin } from "@/lib/mcp/oauth/config";
|
||||
import {
|
||||
getCorrelation,
|
||||
compareMetric,
|
||||
getMetricBaseline,
|
||||
detectChangepoints,
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
|
||||
/**
|
||||
@@ -283,6 +289,97 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
];
|
||||
}
|
||||
|
||||
// ── Phase 4 deep-value output schemas ────────────────────────────────
|
||||
// Every field except `present` is optional so a grounded `{ present: false }`
|
||||
// miss and a full hit both conform — the SDK validates `structuredContent`
|
||||
// against these (REQ: structuredContent + outputSchema on the rich reads).
|
||||
|
||||
const bandShape = z
|
||||
.object({ low: z.number(), high: z.number() })
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const getCorrelationOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
pair: z
|
||||
.object({
|
||||
behaviour: z.string(),
|
||||
outcome: z.string(),
|
||||
direction: z.enum(["higher", "lower"]),
|
||||
lagDays: z.number(),
|
||||
n: z.number(),
|
||||
r: z.number(),
|
||||
note: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
pairsTested: z.number().optional(),
|
||||
windowDays: z.number().optional(),
|
||||
association: z.literal("descriptive").optional(),
|
||||
};
|
||||
|
||||
const metricWindowSnapshotSchema = z.object({
|
||||
label: z.string(),
|
||||
unit: z.string(),
|
||||
band: bandShape,
|
||||
windowDays: z.number(),
|
||||
granularity: z.string(),
|
||||
count: z.number(),
|
||||
mean: z.number().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
});
|
||||
|
||||
const compareMetricOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
mode: z.enum(["metric_vs_metric", "window_vs_window"]).optional(),
|
||||
a: metricWindowSnapshotSchema.optional(),
|
||||
b: metricWindowSnapshotSchema.optional(),
|
||||
delta: z
|
||||
.object({ mean: z.number(), pct: z.number().nullable() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
};
|
||||
|
||||
const getMetricBaselineOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
metric: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
baseline: z
|
||||
.object({ low: z.number(), high: z.number(), sampleDays: z.number() })
|
||||
.optional(),
|
||||
latest: z.number().optional(),
|
||||
placement: z.enum(["within", "above", "below"]).optional(),
|
||||
referenceBand: bandShape,
|
||||
driver: z
|
||||
.object({ note: z.string(), behaviour: z.string(), outcome: z.string() })
|
||||
.nullable()
|
||||
.optional(),
|
||||
};
|
||||
|
||||
const detectChangepointsOutput: z.ZodRawShape = {
|
||||
present: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
metric: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
granularity: z.string().optional(),
|
||||
windowDays: z.number().optional(),
|
||||
bucketsAnalysed: z.number().optional(),
|
||||
changepoints: z
|
||||
.array(
|
||||
z.object({
|
||||
at: z.string(),
|
||||
direction: z.enum(["increase", "decrease"]),
|
||||
beforeMean: z.number(),
|
||||
afterMean: z.number(),
|
||||
delta: z.number(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
};
|
||||
|
||||
export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
{
|
||||
name: "list_metrics",
|
||||
@@ -357,6 +454,81 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
return runCoachTool(ctx, "get_correlations", args);
|
||||
},
|
||||
},
|
||||
// ── Phase 4 deep-value reads (catalogue §5 #1, #3, #4, #7) ──────────
|
||||
{
|
||||
name: "get_correlation",
|
||||
title: "Get a correlation between two metrics",
|
||||
description:
|
||||
"Fetch the statistically-vetted (FDR-controlled), lag-aware association between TWO named metrics the user tracks (e.g. 'sleep' and 'resting heart rate'). Re-exports the same discovery engine the other correlation surfaces run; returns the matched pair's direction, lag, sample size, Pearson r, and a descriptive — never causal — note. Returns { present: false } when no significant pattern links the pair (sparse data or the link did not clear the engine's floors).",
|
||||
inputShape: {
|
||||
metricA: z.string().min(1).max(60),
|
||||
metricB: z.string().min(1).max(60),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getCorrelationOutput,
|
||||
run(ctx, args) {
|
||||
return getCorrelation(ctx.userId, {
|
||||
metricA: String(args.metricA ?? ""),
|
||||
metricB: String(args.metricB ?? ""),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compare_metric",
|
||||
title: "Compare metrics or windows",
|
||||
description:
|
||||
"Compare one metric against another over the same trailing window, OR a single metric across two trailing windows (e.g. last 30 days vs last 90 days). Returns each side's rollup statistics (count, mean, min, max) with units and reference bands, plus a delta + percent change when both sides share a unit. Windows are trailing-to-now. Returns { present: false } when a side has no data.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
metricB: z.string().min(1).max(60).optional(),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
windowB: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: compareMetricOutput,
|
||||
run(ctx, args) {
|
||||
return compareMetric(ctx.userId, {
|
||||
metric: String(args.metric ?? ""),
|
||||
metricB: typeof args.metricB === "string" ? args.metricB : undefined,
|
||||
window: args.window as never,
|
||||
windowB: args.windowB as never,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_metric_baseline",
|
||||
title: "Get a metric's personal baseline",
|
||||
description:
|
||||
"Fetch where the user's latest reading for ONE metric sits against their own usual range (median ± robust deviation), plus the strongest lagged driver of that metric. Re-exports the same baseline engine the metric page renders. Returns the personal band, today's value + placement (within/above/below), and the population reference band. Returns { present: false } with reason 'insufficient_history' below the 7-day learning floor — never a fabricated range.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: getMetricBaselineOutput,
|
||||
run(ctx, args) {
|
||||
return getMetricBaseline(ctx.userId, {
|
||||
metric: String(args.metric ?? ""),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "detect_changepoints",
|
||||
title: "Detect level shifts in a metric",
|
||||
description:
|
||||
"Surface points where a metric's level shifted over a trailing window (e.g. 'when did my weight trend change?'). Runs a conservative changepoint scan over the rollup tier's bucket means and reports each shift's date, direction, and before/after means with units. High firing bar — returns { present: false } when too little data exists or no shift clears the threshold.",
|
||||
inputShape: {
|
||||
metric: z.string().min(1).max(60),
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
},
|
||||
annotations: READ_ONLY_ANNOTATIONS,
|
||||
outputShape: detectChangepointsOutput,
|
||||
run(ctx, args) {
|
||||
return detectChangepoints(ctx.userId, {
|
||||
metric: String(args.metric ?? ""),
|
||||
window: args.window as never,
|
||||
});
|
||||
},
|
||||
},
|
||||
...searchAndFetchTools(),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user