mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +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")>();
|
const actual = await importOriginal<typeof import("../nutrients-read")>();
|
||||||
return { ...actual, getNutrients: vi.fn(async () => ({ present: true })) };
|
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
|
// 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
|
// so the registry-wide loops (surface / annotation / no-verdict) never reach a
|
||||||
// real engine. Their own logic is covered in `rich-reads.test.ts`.
|
// 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 { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||||
import { getNutrients } from "@/lib/mcp/nutrients-read";
|
import { getNutrients } from "@/lib/mcp/nutrients-read";
|
||||||
|
import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io";
|
||||||
import type { McpAuthContext } from "../auth";
|
import type { McpAuthContext } from "../auth";
|
||||||
|
|
||||||
const CTX: McpAuthContext = {
|
const CTX: McpAuthContext = {
|
||||||
@@ -150,6 +166,8 @@ describe("MCP tool registry — surface", () => {
|
|||||||
"get_preventive_care",
|
"get_preventive_care",
|
||||||
// v1.30 coverage review (G1) — the nutrients pipeline.
|
// v1.30 coverage review (G1) — the nutrients pipeline.
|
||||||
"get_nutrients",
|
"get_nutrients",
|
||||||
|
// v1.30 coverage review (G2) — the intraday pulse / shape of the day.
|
||||||
|
"get_intraday_pulse",
|
||||||
].sort(),
|
].sort(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -804,3 +822,66 @@ describe("nutrients on search / fetch (v1.30 coverage review G1)", () => {
|
|||||||
expect(result.title).toBe("Not found");
|
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,
|
type NutrientsDailyResult,
|
||||||
} from "@/lib/mcp/nutrients-read";
|
} from "@/lib/mcp/nutrients-read";
|
||||||
import { NUTRIENT_CODES } from "@/lib/nutrients/catalog";
|
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 { encodeOffsetCursor, decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||||
import {
|
import {
|
||||||
computeDisplayDue,
|
computeDisplayDue,
|
||||||
@@ -889,6 +891,44 @@ const getNutrientsOutput: z.ZodRawShape = {
|
|||||||
.optional(),
|
.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[] = [
|
export const MCP_TOOLS: McpToolDefinition[] = [
|
||||||
{
|
{
|
||||||
name: "list_metrics",
|
name: "list_metrics",
|
||||||
@@ -1496,6 +1536,64 @@ export const MCP_TOOLS: McpToolDefinition[] = [
|
|||||||
return result;
|
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(),
|
...searchAndFetchTools(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user