mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
e062721812
commit
c6b8d33b0c
2 files changed
+179
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user