mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/v125-mcp-completeness' into release/v1.25.0
This commit is contained in:
commit
444da8fbb3
6 files changed
+345
-1
No files matched your search
@@ -95,6 +95,33 @@ Every read tool is annotated read-only / non-destructive / idempotent /
|
||||
closed-world — the read-only guarantee is structural (only read tools are
|
||||
registered for a read session), and the annotation merely advertises it.
|
||||
|
||||
### Clinical signals
|
||||
|
||||
The v1.25 clinical-signals set — **grip strength**, **pain (0–10 NRS)**,
|
||||
**waist circumference**, and **waist-to-height ratio** — is reachable
|
||||
through the rollup-backed reads `get_metric_baseline`, `compare_metric`,
|
||||
and `detect_changepoints` (pass the signal name, e.g. `grip strength` or
|
||||
`waist-to-height ratio`), and through `search` → `fetch` (`search`
|
||||
surfaces a signal only when the user has recorded it; `fetch metric:<KEY>`
|
||||
hydrates it). Each carries its unit and population reference band from the
|
||||
single signal registry; absence is the usual `{ present: false }`. They
|
||||
sit off the Coach data inventory by design, so `list_metrics` /
|
||||
`get_metric_series` do not enumerate them — use the reads above.
|
||||
|
||||
The **longevity lab panel** (ApoB, Lp(a), hs-CRP, HbA1c, fasting glucose,
|
||||
fasting insulin, eGFR, GGT, ferritin, omega-3 index) is reached like any
|
||||
other biomarker: `get_labs` (latest per analyte or, with `history:true`, a
|
||||
paginated trajectory), the `healthlog://lab/{analyte}` resource template,
|
||||
and `search` → `fetch` (`lab:<analyte>`). Reference bounds come from the
|
||||
curated biomarker catalogue.
|
||||
|
||||
**Off the MCP surface by construction.** The PHQ-9 / GAD-7 mental-health
|
||||
screeners (item content and totals) are excluded from AI / MCP, and the
|
||||
environmental-context signals (`ENV_*`: temperature, sunshine, daylight,
|
||||
precipitation, barometric pressure) are off MCP in v1. None of them
|
||||
resolve through any read tool — they are not on the MCP exposure
|
||||
allowlist.
|
||||
|
||||
## Resources
|
||||
|
||||
Read-only context the assistant can attach alongside tool results. Like
|
||||
|
||||
@@ -80,6 +80,49 @@ describe("resolveRichMetric", () => {
|
||||
expect(resolveRichMetric("bananas")).toBeNull();
|
||||
expect(resolveRichMetric("")).toBeNull();
|
||||
});
|
||||
|
||||
// ── v1.25 clinical signals (registry-grounded, MCP allowlist) ──────────
|
||||
it("resolves the v1.25 clinical signals from the registry with units + bands", () => {
|
||||
const grip = resolveRichMetric("grip strength");
|
||||
expect(grip?.measurementType).toBe("GRIP_STRENGTH");
|
||||
expect(grip?.unit).toBe("kg");
|
||||
expect(grip?.band).toEqual({ low: 16, high: 60 });
|
||||
|
||||
expect(resolveRichMetric("pain")?.measurementType).toBe("PAIN_NRS");
|
||||
expect(resolveRichMetric("waist")?.measurementType).toBe(
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
);
|
||||
|
||||
const whtr = resolveRichMetric("waist-to-height ratio");
|
||||
expect(whtr?.measurementType).toBe("WAIST_TO_HEIGHT");
|
||||
expect(whtr?.band).toEqual({ low: 0, high: 0.5 });
|
||||
|
||||
// The exact registry key resolves too.
|
||||
expect(resolveRichMetric("GRIP_STRENGTH")?.measurementType).toBe(
|
||||
"GRIP_STRENGTH",
|
||||
);
|
||||
});
|
||||
|
||||
it("NEVER resolves the mental-health screeners or environmental signals (safety)", () => {
|
||||
// PHQ-9 / GAD-7 item content + totals are excluded from AI/MCP by
|
||||
// construction; the environmental signals are off MCP in v1. None of them
|
||||
// are on the MCP clinical allowlist, so resolution must return null.
|
||||
for (const off of [
|
||||
"PHQ9_SCORE",
|
||||
"phq9 score",
|
||||
"phq-9",
|
||||
"GAD7_SCORE",
|
||||
"gad7 score",
|
||||
"gad-7",
|
||||
"ENV_TEMP_MEAN",
|
||||
"ENV_SUNSHINE",
|
||||
"ENV_PRESSURE_MEAN",
|
||||
"daily temperature",
|
||||
"barometric pressure",
|
||||
]) {
|
||||
expect(resolveRichMetric(off)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── 1. get_correlation ───────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
labResult: { findMany: vi.fn() },
|
||||
// v1.25 — `search` probes the clinical-signal measurement types.
|
||||
measurement: { groupBy: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({
|
||||
findFirst: vi.fn(async () => null),
|
||||
},
|
||||
labResult: { findMany: vi.fn(async () => []) },
|
||||
// v1.25 — `search` probes the clinical-signal measurement types.
|
||||
measurement: { groupBy: vi.fn(async () => []) },
|
||||
// v1.24 — operational reads (schedule / integration status / preventive care).
|
||||
user: { findUnique: vi.fn(async () => ({ timezone: "UTC" })) },
|
||||
medicationIntakeEvent: {
|
||||
@@ -56,9 +58,18 @@ vi.mock("../rich-reads", () => ({
|
||||
detectChangepoints: vi.fn(async () => ({ present: true })),
|
||||
getLabHistory: vi.fn(async () => ({ present: true })),
|
||||
LAB_HISTORY_MAX_LIMIT: 50,
|
||||
// v1.25 — the clinical-signal allowlist `search` / `fetch` consume.
|
||||
MCP_CLINICAL_SIGNALS: [
|
||||
{
|
||||
key: "GRIP_STRENGTH",
|
||||
measurementType: "GRIP_STRENGTH",
|
||||
label: "Grip strength",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { MCP_TOOLS, MCP_TOOL_NAMES } from "../tools";
|
||||
import { getMetricBaseline } from "../rich-reads";
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
import { buildCoachDataInventory } from "@/lib/ai/coach/tools/inventory";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -567,3 +578,66 @@ describe("search — cursor pagination", () => {
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.25 clinical signals on the MCP surface", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(buildCoachDataInventory).mockResolvedValue({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
probeScope: { sources: [] },
|
||||
} as never);
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("search surfaces a present clinical signal as metric:<KEY>", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{ type: "GRIP_STRENGTH" },
|
||||
] as never);
|
||||
const result = (await tool("search").run(CTX, { query: "grip" })) as {
|
||||
results: Array<{ id: string; title: string; url: string }>;
|
||||
};
|
||||
const hit = result.results.find((r) => r.id === "metric:GRIP_STRENGTH");
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.title).toBe("Grip strength");
|
||||
expect(hit?.url).toContain("/insights?metric=GRIP_STRENGTH");
|
||||
});
|
||||
|
||||
it("search omits a clinical signal with no recorded data", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
const result = (await tool("search").run(CTX, { query: "grip" })) as {
|
||||
results: Array<{ id: string }>;
|
||||
};
|
||||
expect(result.results.some((r) => r.id.startsWith("metric:"))).toBe(false);
|
||||
});
|
||||
|
||||
it("fetch hydrates a clinical signal via the baseline read (not the Coach path)", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
vi.mocked(getMetricBaseline).mockResolvedValue({
|
||||
present: true,
|
||||
metric: "Grip strength",
|
||||
unit: "kg",
|
||||
latest: 34,
|
||||
placement: "within",
|
||||
baseline: { low: 30, high: 38, sampleDays: 21 },
|
||||
referenceBand: { low: 16, high: 60 },
|
||||
} as never);
|
||||
|
||||
const result = (await tool("fetch").run(CTX, {
|
||||
id: "metric:GRIP_STRENGTH",
|
||||
})) as Record<string, unknown>;
|
||||
|
||||
// Resolved through the rollup-backed baseline read, never the Coach executor.
|
||||
expect(getMetricBaseline).toHaveBeenCalledWith("user-1", {
|
||||
metric: "GRIP_STRENGTH",
|
||||
});
|
||||
expect(executeCoachTool).not.toHaveBeenCalled();
|
||||
expect(result.title).toBe("Grip strength");
|
||||
// Plain-text prose, grounded with the value + band; never a JSON blob.
|
||||
expect(result.text as string).not.toContain("{");
|
||||
expect(result.text as string).toContain("34");
|
||||
expect(result.text as string).toContain("16–60");
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
getMetricStatusMeta,
|
||||
METRIC_STATUS_IDS,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { getSignal } from "@/lib/signals/registry";
|
||||
import { classifyReferenceRange } from "@/lib/labs/reference-range";
|
||||
import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
|
||||
@@ -177,6 +178,104 @@ function fromRegistry(id: string): RichMetric | null {
|
||||
};
|
||||
}
|
||||
|
||||
// ── v1.25 clinical signals (registry-grounded, MCP-owned exposure) ────
|
||||
//
|
||||
// The v1.25 clinical-signals wave added a set of physical / clinical
|
||||
// measurements to the signal registry. They sit OFF the Coach snapshot by
|
||||
// design (`coachSnapshot:false ⇒ surfaces.mcp:false`), so the Coach-driven
|
||||
// reads (`get_metric_series`, the data inventory) never surface them. The MCP
|
||||
// layer exposes them here through the rollup-backed rich reads — `compare_metric`,
|
||||
// `get_metric_baseline`, `detect_changepoints` — and through `search` / `fetch`.
|
||||
//
|
||||
// Exposure is an EXPLICIT allowlist owned by this module, not the registry's own
|
||||
// `surfaces.mcp` flag: the registry keeps these off the Coach surface, while the
|
||||
// MCP read surface opts them in one signal at a time. The registry remains the
|
||||
// single source of truth for grounding (the backing `MeasurementType`, unit, and
|
||||
// population band). Deliberately ABSENT and therefore unreachable over MCP: the
|
||||
// PHQ-9 / GAD-7 mental-health screeners and every environmental (`ENV_*`) signal
|
||||
// — they never reach AI / MCP by construction.
|
||||
export const MCP_CLINICAL_SIGNAL_KEYS = [
|
||||
"GRIP_STRENGTH",
|
||||
"PAIN_NRS",
|
||||
"WAIST_CIRCUMFERENCE",
|
||||
"WAIST_TO_HEIGHT",
|
||||
] as const;
|
||||
|
||||
/** Build a `RichMetric` from a registry signal key (measurement-kind only). */
|
||||
function richMetricFromSignal(key: string): RichMetric | null {
|
||||
const sig = getSignal(key);
|
||||
if (!sig || sig.kind !== "measurement") return null;
|
||||
return {
|
||||
measurementType: sig.source.measurementType,
|
||||
label: sig.displayName,
|
||||
unit: sig.unit,
|
||||
band: sig.normalRange
|
||||
? { low: sig.normalRange.low, high: sig.normalRange.high }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved clinical signals, keyed by their registry key (built once). */
|
||||
const CLINICAL_SIGNAL_BY_KEY = new Map<string, RichMetric>();
|
||||
for (const key of MCP_CLINICAL_SIGNAL_KEYS) {
|
||||
const metric = richMetricFromSignal(key);
|
||||
if (metric) CLINICAL_SIGNAL_BY_KEY.set(key, metric);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clinical signals the MCP surface exposes, for the `search` discovery
|
||||
* probe: the registry key (the `metric:` id `fetch` resolves), the backing
|
||||
* `MeasurementType` (the presence probe), and the display label.
|
||||
*/
|
||||
export const MCP_CLINICAL_SIGNALS: ReadonlyArray<{
|
||||
key: string;
|
||||
measurementType: MeasurementType;
|
||||
label: string;
|
||||
}> = [...CLINICAL_SIGNAL_BY_KEY.entries()].map(([key, m]) => ({
|
||||
key,
|
||||
measurementType: m.measurementType,
|
||||
label: m.label,
|
||||
}));
|
||||
|
||||
/** Friendly aliases (NL phrasings) → clinical signal key. */
|
||||
const CLINICAL_ALIASES: Record<string, string> = {
|
||||
grip: "GRIP_STRENGTH",
|
||||
grip_strength: "GRIP_STRENGTH",
|
||||
hand_grip: "GRIP_STRENGTH",
|
||||
hand_grip_strength: "GRIP_STRENGTH",
|
||||
pain: "PAIN_NRS",
|
||||
pain_nrs: "PAIN_NRS",
|
||||
pain_score: "PAIN_NRS",
|
||||
waist: "WAIST_CIRCUMFERENCE",
|
||||
waist_circumference: "WAIST_CIRCUMFERENCE",
|
||||
whtr: "WAIST_TO_HEIGHT",
|
||||
waist_to_height: "WAIST_TO_HEIGHT",
|
||||
waist_to_height_ratio: "WAIST_TO_HEIGHT",
|
||||
waist_height_ratio: "WAIST_TO_HEIGHT",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a free-text name to a clinical signal the MCP layer exposes, or
|
||||
* `null`. Closed by construction to the `MCP_CLINICAL_SIGNAL_KEYS` allowlist, so
|
||||
* a screener key (PHQ9_SCORE / GAD7_SCORE) or an `ENV_*` key can never resolve
|
||||
* here even though they exist in the registry.
|
||||
*/
|
||||
function resolveClinicalSignal(key: string): RichMetric | null {
|
||||
const aliased = CLINICAL_ALIASES[key];
|
||||
if (aliased) return CLINICAL_SIGNAL_BY_KEY.get(aliased) ?? null;
|
||||
const upper = key.toUpperCase();
|
||||
if (CLINICAL_SIGNAL_BY_KEY.has(upper)) {
|
||||
return CLINICAL_SIGNAL_BY_KEY.get(upper) ?? null;
|
||||
}
|
||||
// Display-name match over the allowlist (e.g. "waist-to-height ratio").
|
||||
for (const [, metric] of CLINICAL_SIGNAL_BY_KEY) {
|
||||
if (metric.label.toLowerCase().replace(/[\s-]+/g, "_") === key) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
return 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
|
||||
@@ -192,6 +291,13 @@ export function resolveRichMetric(input: string): RichMetric | null {
|
||||
// 1. explicit supplement (weight / pulse / bmi).
|
||||
if (SUPPLEMENT[key]) return SUPPLEMENT[key];
|
||||
|
||||
// 1b. v1.25 clinical signals the MCP layer opts in (grip strength, pain NRS,
|
||||
// waist circumference, waist-to-height). Registry-grounded; the allowlist is
|
||||
// closed, so a mental-health screener or an environmental signal never
|
||||
// resolves here.
|
||||
const clinical = resolveClinicalSignal(key);
|
||||
if (clinical) return clinical;
|
||||
|
||||
// 2. friendly alias → registry id.
|
||||
const aliased = ALIASES[key];
|
||||
if (aliased) return fromRegistry(aliased);
|
||||
|
||||
+93
-1
@@ -35,6 +35,7 @@ import {
|
||||
detectChangepoints,
|
||||
getLabHistory,
|
||||
LAB_HISTORY_MAX_LIMIT,
|
||||
MCP_CLINICAL_SIGNALS,
|
||||
type DateRange,
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
@@ -188,6 +189,55 @@ function plainLabText(rid: string, result: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a `fetch` result for a v1.25 clinical signal (grip strength, pain NRS,
|
||||
* waist circumference, waist-to-height) as a plain-text citation sentence. These
|
||||
* sit off the Coach snapshot, so they hydrate through the rollup-backed baseline
|
||||
* read (`get_metric_baseline`) rather than the Coach `get_metric_series` path:
|
||||
* the latest reading, where it sits against the user's own usual range, and the
|
||||
* population reference band. Quotes only values the result carried; never
|
||||
* fabricates a figure or asserts a clinical verdict.
|
||||
*/
|
||||
function plainClinicalText(
|
||||
label: string,
|
||||
result: {
|
||||
present?: boolean;
|
||||
reason?: string;
|
||||
unit?: string;
|
||||
latest?: number;
|
||||
placement?: "within" | "above" | "below";
|
||||
baseline?: { low: number; high: number };
|
||||
referenceBand?: { low: number; high: number } | null;
|
||||
},
|
||||
): string {
|
||||
const unit = result.unit ? ` ${result.unit}` : "";
|
||||
const band = result.referenceBand
|
||||
? ` Reference band ${result.referenceBand.low}–${result.referenceBand.high}${unit}.`
|
||||
: "";
|
||||
if (!result.present) {
|
||||
if (result.reason === "insufficient_history") {
|
||||
return `${label}: still learning your usual range — too little history yet.${band}`;
|
||||
}
|
||||
return `No recent ${label.toLowerCase()} data is on file.`;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (typeof result.latest === "number") {
|
||||
parts.push(`latest ${round1(result.latest)}${unit}`);
|
||||
}
|
||||
if (result.placement && result.baseline) {
|
||||
parts.push(
|
||||
`${result.placement} your usual ${round1(result.baseline.low)}–${round1(
|
||||
result.baseline.high,
|
||||
)}${unit}`,
|
||||
);
|
||||
}
|
||||
const head =
|
||||
parts.length > 0
|
||||
? `${label}: ${parts.join(", ")}.`
|
||||
: `${label}: a reading is on file.`;
|
||||
return `${head}${band}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The `search` + `fetch` pair — the de-facto two-tool retrieval convention and,
|
||||
* critically, the ONLY tools ChatGPT can call in its default (non-Developer)
|
||||
@@ -288,6 +338,30 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
});
|
||||
}
|
||||
|
||||
// v1.25 clinical signals (grip strength, pain NRS, waist / WHtR) — off
|
||||
// the Coach data inventory by design, so they are surfaced here directly.
|
||||
// One grouped presence probe over their backing measurement types;
|
||||
// present-only, in a stable (allowlist) order. `fetch metric:<KEY>`
|
||||
// hydrates each via the rollup-backed baseline read.
|
||||
const clinicalPresent = await prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId: ctx.userId,
|
||||
type: { in: MCP_CLINICAL_SIGNALS.map((s) => s.measurementType) },
|
||||
},
|
||||
});
|
||||
const presentTypes = new Set(clinicalPresent.map((r) => r.type));
|
||||
for (const sig of MCP_CLINICAL_SIGNALS) {
|
||||
if (!presentTypes.has(sig.measurementType)) continue;
|
||||
const hay = `${sig.label} ${sig.key}`.toLowerCase();
|
||||
if (query && !hay.includes(query)) continue;
|
||||
results.push({
|
||||
id: `metric:${sig.key}`,
|
||||
title: sig.label,
|
||||
url: `${origin}/insights?metric=${encodeURIComponent(sig.key)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Cursor pagination over the assembled result set (was a silent
|
||||
// slice(0,50)). The set is rebuilt deterministically each call (stable
|
||||
// ordering: metrics → medications → labs), so an opaque offset cursor
|
||||
@@ -334,6 +408,24 @@ function searchAndFetchTools(): McpToolDefinition[] {
|
||||
});
|
||||
|
||||
if (kind === "metric" && rid) {
|
||||
// v1.25 clinical signals sit off the Coach snapshot, so hydrate them
|
||||
// through the rollup-backed baseline read rather than the Coach
|
||||
// `get_metric_series` path (which would report no data for them).
|
||||
const clinical = MCP_CLINICAL_SIGNALS.find(
|
||||
(s) => s.key.toLowerCase() === rid.toLowerCase(),
|
||||
);
|
||||
if (clinical) {
|
||||
const baseline = await getMetricBaseline(ctx.userId, {
|
||||
metric: clinical.key,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
title: clinical.label,
|
||||
text: plainClinicalText(clinical.label, baseline),
|
||||
url: `${origin}/insights?metric=${encodeURIComponent(clinical.key)}`,
|
||||
metadata: { type: "metric", metric: clinical.key },
|
||||
};
|
||||
}
|
||||
const result = await executeCoachTool({
|
||||
userId: ctx.userId,
|
||||
name: "get_metric_series",
|
||||
@@ -823,7 +915,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
||||
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.",
|
||||
"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. Also resolves the clinical-signal metrics (grip strength, pain 0–10 NRS, waist circumference, waist-to-height ratio). 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),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user