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
@@ -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