feat(mcp): add get_intraday_pulse read tool

Adds MCP coverage for the intraday pulse / "shape of the day" —
previously unreachable, since get_metric_series only serves daily
aggregates and cannot answer "what happened this afternoon?" or "was I
tense during the meeting?".

get_intraday_pulse is a thin wrapper over loadIntradayPulse, returning
the same DTO GET /api/insights/pulse/intraday serves verbatim: the
10-minute mean shape (dense-90d, hourly-grain fallback outside that
window per the resolution field), the resting baseline, and the
cautious elevated-at-rest tension window when every confidence gate
holds. Gated on the insights module; never fabricates a reading for a
day with no data.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 07:56:14 +02:00
1 parent e062721812
commit c6b8d33b0c
2 files changed
+179

No files matched your search

+81
View File
@@ -68,6 +68,21 @@ vi.mock("@/lib/mcp/nutrients-read", async (importOriginal) => {
const actual = await importOriginal<typeof import("../nutrients-read")>();
return { ...actual, getNutrients: vi.fn(async () => ({ present: true })) };
});
// v1.30 (G2) — `get_intraday_pulse` delegates to the shared IO seam; stub it
// so the tool-wiring tests below never reach a real DB read. The engine's own
// logic (dense-90d + hourly fallback + tension) is covered elsewhere.
vi.mock("@/lib/analytics/intraday-pulse-io", () => ({
loadIntradayPulse: vi.fn(async () => ({
dateKey: "2026-07-10",
timezone: "UTC",
bucketMinutes: 10,
series: [{ startMinute: 0, mean: 60, count: 3 }],
baseline: 58,
baselineSource: "resting",
tension: null,
resolution: "tenMin",
})),
}));
// 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`.
@@ -98,6 +113,7 @@ import { getIntegrationStatus } from "@/lib/integrations/status";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
import { isModuleEnabled } from "@/lib/modules/gate";
import { getNutrients } from "@/lib/mcp/nutrients-read";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import type { McpAuthContext } from "../auth";
const CTX: McpAuthContext = {
@@ -150,6 +166,8 @@ describe("MCP tool registry — surface", () => {
"get_preventive_care",
// v1.30 coverage review (G1) — the nutrients pipeline.
"get_nutrients",
// v1.30 coverage review (G2) — the intraday pulse / shape of the day.
"get_intraday_pulse",
].sort(),
);
});
@@ -804,3 +822,66 @@ describe("nutrients on search / fetch (v1.30 coverage review G1)", () => {
expect(result.title).toBe("Not found");
});
});
describe("get_intraday_pulse — v1.30 coverage review (G2)", () => {
it("returns the engine's DTO verbatim (present, resolution, tension included)", async () => {
const result = (await tool("get_intraday_pulse").run(CTX, {})) as {
present: boolean;
dateKey: string;
resolution: string;
series: unknown[];
tension: unknown;
};
// No `date` arg → today's local day; assert the session tz was threaded
// through without pinning a calendar date the test would rot on.
expect(loadIntradayPulse).toHaveBeenCalledWith(
"user-1",
"UTC",
expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
);
expect(result.present).toBe(true);
// The mocked engine's own DTO carries this fixed dateKey verbatim.
expect(result.dateKey).toBe("2026-07-10");
expect(result.resolution).toBe("tenMin");
expect(result.series).toHaveLength(1);
expect(result.tension).toBeNull();
});
it("passes an explicit `date` arg straight through to the engine", async () => {
await tool("get_intraday_pulse").run(CTX, { date: "2026-06-01" });
expect(loadIntradayPulse).toHaveBeenCalledWith(
"user-1",
"UTC",
"2026-06-01",
);
});
it("returns { present: false } (never fabricates) when the day has no pulse data", async () => {
vi.mocked(loadIntradayPulse).mockResolvedValueOnce({
dateKey: "2026-07-10",
timezone: "UTC",
bucketMinutes: 10,
series: [],
baseline: null,
baselineSource: "none",
tension: null,
resolution: "tenMin",
} as never);
const result = (await tool("get_intraday_pulse").run(CTX, {})) as {
present: boolean;
reason?: string;
};
expect(result.present).toBe(false);
expect(result.reason).toBe("no_data");
});
it("returns { present: false, reason: module_disabled } when the `insights` module is off", async () => {
vi.mocked(isModuleEnabled).mockResolvedValueOnce(false);
const result = (await tool("get_intraday_pulse").run(CTX, {})) as {
present: boolean;
reason?: string;
};
expect(result).toEqual({ present: false, reason: "module_disabled" });
expect(loadIntradayPulse).not.toHaveBeenCalled();
});
});
+98
View File
@@ -46,6 +46,8 @@ import {
type NutrientsDailyResult,
} from "@/lib/mcp/nutrients-read";
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
import { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
import {
computeDisplayDue,
@@ -889,6 +891,44 @@ const getNutrientsOutput: z.ZodRawShape = {
.optional(),
};
/**
* Output schema for `get_intraday_pulse` — the 10-minute "shape of the day"
* plus, when every confidence gate holds, one cautious elevated-at-rest
* ("tension") window. Verbatim re-export of `IntradayPulseResult`
* (`intraday-pulse-io.ts`); `resolution` tells the caller whether `series` is
* the dense 10-minute grain or the coarser hourly fallback.
*/
const getIntradayPulseOutput: z.ZodRawShape = {
present: z.boolean(),
reason: z.string().optional(),
dateKey: z.string().optional(),
timezone: z.string().optional(),
bucketMinutes: z.number().optional(),
resolution: z.enum(["tenMin", "hourly"]).optional(),
series: z
.array(
z.object({
startMinute: z.number(),
mean: z.number(),
count: z.number(),
}),
)
.optional(),
baseline: z.number().nullable().optional(),
baselineSource: z.enum(["resting", "proxy", "none"]).optional(),
tension: z
.object({
startMinute: z.number(),
endMinute: z.number(),
partOfDay: z.enum(["morning", "afternoon", "evening", "night"]),
meanHr: z.number(),
baseline: z.number(),
hrvConfirmed: z.boolean(),
})
.nullable()
.optional(),
};
export const MCP_TOOLS: McpToolDefinition[] = [
{
name: "list_metrics",
@@ -1496,6 +1536,64 @@ export const MCP_TOOLS: McpToolDefinition[] = [
return result;
},
},
// ── v1.30 coverage review (G2) — the intraday pulse / "shape of the day" ──
{
name: "get_intraday_pulse",
title: "Get the intraday pulse shape",
description:
"Fetch the user's own 10-minute heart-rate shape for ONE local day — 'what happened this afternoon?', 'was I tense during the meeting?'. When every confidence gate holds, also carries a single cautious elevated-at-rest ('tension') window: a DESCRIPTIVE pattern only, NEVER a stress diagnosis. Falls back to an hourly-grain shape for a day outside the dense-retention window — see the `resolution` field ('tenMin' vs 'hourly'); `tension` is only ever computed at the dense grain. Gated on the `insights` module. Returns { present: false, reason: \"module_disabled\" } when the module is off, or { present: false } when the day has no pulse data.",
inputShape: {
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.describe(
"YYYY-MM-DD in the user's own timezone. Defaults to the user's local today.",
),
},
annotations: READ_ONLY_ANNOTATIONS,
outputShape: getIntradayPulseOutput,
async run(ctx, args) {
const enabled = await isModuleEnabled(ctx.userId, "insights");
if (!enabled) {
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_intraday_pulse", present: false },
});
return { present: false, reason: "module_disabled" };
}
const user = await prisma.user.findUnique({
where: { id: ctx.userId },
select: { timezone: true },
});
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const dateKey =
typeof args.date === "string" && args.date
? args.date
: userDayKey(new Date(), timezone);
const result = await loadIntradayPulse(ctx.userId, timezone, dateKey);
const present = result.series.length > 0;
annotate({
action: { name: "mcp.tool.invoked" },
meta: { tool: "get_intraday_pulse", present },
});
return {
present,
...(present ? {} : { reason: "no_data" }),
dateKey: result.dateKey,
timezone: result.timezone,
bucketMinutes: result.bucketMinutes,
resolution: result.resolution,
series: result.series,
baseline: result.baseline,
baselineSource: result.baselineSource,
tension: result.tension,
};
},
},
...searchAndFetchTools(),
];