mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(coach): retrieve health data on demand via tools
Replace snapshot-stuffing in the Coach turn with a small base context plus
on-demand retrieval. The model receives the full system prompt, a tool-mode
grounding addendum, and a tiny DATA INVENTORY manifest listing which domains the
user has logged and which tool fetches each; the figures themselves are pulled
only when a question needs them.
Six read-only tools wrap the existing server-authoritative snapshot builder:
get_metric_series (blood pressure / weight / pulse + the additive series),
get_glucose_panel, get_sleep, get_medication_compliance, get_labs, and
get_illness_recovery. Each returns a structured { present: false } for an absent
or opted-out domain rather than throwing or returning an ambiguous empty list,
so the model can always tell missing data from real data. Module and cycle gates
are enforced at the builder, so opted-out domains are unfetchable.
A bounded loop runs the call -> tool-result -> answer cycle: at most two rounds
with a hard cap of three that forces a final answer, parallel tool execution,
and only the final round streamed. The day's budget reserves the per-call
ceiling times the round count and reconciles the summed tokens; the atomic
reserve and reconcile primitives are unchanged. All inbound gates (rate limit,
refusal, replay scan, consent, budget) stay before the loop.
The legacy snapshot path stays alive as the no-tools fallback: tool mode is on
only when every provider in the chain supports tool-calling, so a chain that
includes a local model falls back to the prior behaviour verbatim. The tool
trace is persisted onto the assistant message provenance.
This commit is contained in:
1 parent
ec77862286
commit
7a9e326e85
15 files changed
+2057
-43
No files matched your search
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — Coach retrieval tool executor: dispatch, grounding, and the
|
||||
* gate-preservation contract. The executor must NEVER throw and must return a
|
||||
* structured `{ present: false }` for an absent domain — the hallucination
|
||||
* audit asserts the model can always tell "no data" from "data".
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
|
||||
const buildCoachSnapshot =
|
||||
vi.fn<(userId: string, scope?: unknown) => Promise<CoachSnapshotResult>>();
|
||||
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: (userId: string, scope?: unknown) =>
|
||||
buildCoachSnapshot(userId, scope),
|
||||
}));
|
||||
|
||||
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
|
||||
|
||||
function snapshot(
|
||||
sections: Record<string, unknown>,
|
||||
referenceGrounding: string | null = null,
|
||||
): CoachSnapshotResult {
|
||||
return {
|
||||
snapshotJson: JSON.stringify(sections),
|
||||
sections,
|
||||
provenance: { windows: [], metrics: [] },
|
||||
referenceGrounding,
|
||||
};
|
||||
}
|
||||
|
||||
describe("executeCoachTool", () => {
|
||||
beforeEach(() => {
|
||||
buildCoachSnapshot.mockReset();
|
||||
});
|
||||
|
||||
it("returns the matching section for get_metric_series when present", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot(
|
||||
{ bloodPressure: { aggregate: { avgSys30: 128 } } },
|
||||
"REFERENCE GROUNDING\nBP systolic 128 …",
|
||||
),
|
||||
);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: "bp" }),
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.data).toMatchObject({
|
||||
metric: "bp",
|
||||
section: { aggregate: { avgSys30: 128 } },
|
||||
});
|
||||
expect(result.grounding).toContain("REFERENCE GROUNDING");
|
||||
});
|
||||
|
||||
it("returns { present: false } (never throws) when the section is absent", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({}));
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: "hrv" }),
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("no_data");
|
||||
expect(result.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("points get_metric_series(glucose) at the dedicated tool", async () => {
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: "glucose" }),
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("use_get_glucose_panel");
|
||||
// Must not even read the snapshot for a wrong-tool call.
|
||||
expect(buildCoachSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid arguments with a grounded miss, not a throw", async () => {
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: "not_a_metric" }),
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("invalid_arguments");
|
||||
});
|
||||
|
||||
it("rejects malformed JSON arguments with a grounded miss", async () => {
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: "{not json",
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("invalid_arguments");
|
||||
});
|
||||
|
||||
it("rejects an unknown tool name without throwing", async () => {
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "drop_table_users",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("unknown_tool");
|
||||
expect(buildCoachSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("degrades a snapshot-builder throw to { present: false }", async () => {
|
||||
buildCoachSnapshot.mockRejectedValue(new Error("db down"));
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_glucose_panel",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("retrieval_failed");
|
||||
});
|
||||
|
||||
it("combines sleep nights + rhythm sections", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({
|
||||
sleep: { timeline: { recent: [] } },
|
||||
sleepRhythm: { sleepDebt: 120, chronotype: "intermediate" },
|
||||
}),
|
||||
);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_sleep",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.data).toMatchObject({
|
||||
nights: { timeline: { recent: [] } },
|
||||
rhythm: { sleepDebt: 120 },
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces glp1 from weeklyContext on get_medication_compliance", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({
|
||||
compliance: { rate: 0.92 },
|
||||
weeklyContext: { glp1: { drug: "—", dose: "—" } },
|
||||
}),
|
||||
);
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_medication_compliance",
|
||||
rawArguments: "{}",
|
||||
});
|
||||
expect(result.present).toBe(true);
|
||||
expect(result.data).toMatchObject({
|
||||
compliance: { rate: 0.92 },
|
||||
glp1: { drug: "—" },
|
||||
});
|
||||
});
|
||||
|
||||
it("filters labs by analyte and reports analyte_not_found", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({
|
||||
labs: { recent: [{ name: "LDL", value: "120" }] },
|
||||
}),
|
||||
);
|
||||
const hit = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_labs",
|
||||
rawArguments: JSON.stringify({ analyte: "ldl" }),
|
||||
});
|
||||
expect(hit.present).toBe(true);
|
||||
expect(hit.data).toMatchObject({ recent: [{ name: "LDL" }] });
|
||||
|
||||
const miss = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_labs",
|
||||
rawArguments: JSON.stringify({ analyte: "glucose" }),
|
||||
});
|
||||
expect(miss.present).toBe(false);
|
||||
expect(miss.reason).toBe("analyte_not_found");
|
||||
});
|
||||
|
||||
it("never passes userId as a tool argument (read from session only)", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(snapshot({}));
|
||||
// A crafted argument trying to smuggle a userId must be rejected by the
|
||||
// strict schema, never forwarded to the read.
|
||||
const result = await executeCoachTool({
|
||||
userId: "u1",
|
||||
name: "get_metric_series",
|
||||
rawArguments: JSON.stringify({ metric: "bp", userId: "victim" }),
|
||||
});
|
||||
expect(result.present).toBe(false);
|
||||
expect(result.reason).toBe("invalid_arguments");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — the DATA INVENTORY manifest: it must mark present/absent per
|
||||
* domain from the snapshot's sections, carry restMode + cycleEnabled, and
|
||||
* render a compact, brand-free, grounding-honest block. The model reads this to
|
||||
* know what is fetchable so it never invents a metric.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot";
|
||||
|
||||
const buildCoachSnapshot = vi.fn<() => Promise<CoachSnapshotResult>>();
|
||||
const isCycleAvailableForUser = vi.fn<() => Promise<boolean>>();
|
||||
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: () => buildCoachSnapshot(),
|
||||
}));
|
||||
vi.mock("@/lib/cycle/gate", () => ({
|
||||
isCycleAvailableForUser: () => isCycleAvailableForUser(),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
} from "@/lib/ai/coach/tools/inventory";
|
||||
|
||||
function snapshot(
|
||||
sections: Record<string, unknown>,
|
||||
counts?: Record<string, number>,
|
||||
): CoachSnapshotResult {
|
||||
return {
|
||||
snapshotJson: JSON.stringify(sections),
|
||||
sections,
|
||||
provenance: {
|
||||
windows: [],
|
||||
metrics: [],
|
||||
...(counts ? { counts: counts as never } : {}),
|
||||
},
|
||||
referenceGrounding: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildCoachDataInventory", () => {
|
||||
beforeEach(() => {
|
||||
buildCoachSnapshot.mockReset();
|
||||
isCycleAvailableForUser.mockReset();
|
||||
isCycleAvailableForUser.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("marks present domains and carries counts + window", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot(
|
||||
{
|
||||
bloodPressure: { aggregate: {} },
|
||||
glucose: { byContext: {} },
|
||||
scope: { window: "last7days" },
|
||||
},
|
||||
{ bp: 42, glucose: 100 },
|
||||
),
|
||||
);
|
||||
const inv = await buildCoachDataInventory("u1", { window: "last7days" });
|
||||
expect(inv.window).toBe("last7days");
|
||||
const bp = inv.entries.find((e) => e.metric === "bp");
|
||||
expect(bp).toMatchObject({ present: true, count: 42 });
|
||||
const glucose = inv.entries.find((e) => e.tool === "get_glucose_panel");
|
||||
expect(glucose).toMatchObject({ present: true, count: 100 });
|
||||
const sleep = inv.entries.find((e) => e.tool === "get_sleep");
|
||||
expect(sleep?.present).toBe(false);
|
||||
});
|
||||
|
||||
it("reports restMode + cycleEnabled", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot({ illness: { restMode: true } }),
|
||||
);
|
||||
isCycleAvailableForUser.mockResolvedValue(true);
|
||||
const inv = await buildCoachDataInventory("u1", undefined);
|
||||
expect(inv.restMode).toBe(true);
|
||||
expect(inv.cycleEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDataInventory", () => {
|
||||
it("renders a grounding-honest, brand-free block", async () => {
|
||||
buildCoachSnapshot.mockResolvedValue(
|
||||
snapshot(
|
||||
{ bloodPressure: {}, scope: { window: "last30days" } },
|
||||
{ bp: 5 },
|
||||
),
|
||||
);
|
||||
isCycleAvailableForUser.mockResolvedValue(false);
|
||||
const inv = await buildCoachDataInventory("u1", undefined);
|
||||
const text = renderDataInventory(inv);
|
||||
expect(text).toContain("DATA INVENTORY");
|
||||
expect(text).toContain("Never cite a figure you did not fetch");
|
||||
expect(text).toContain("blood pressure: present → get_metric_series");
|
||||
// brand-free: no vendor names.
|
||||
expect(text.toLowerCase()).not.toContain("withings");
|
||||
expect(text.toLowerCase()).not.toContain("oura");
|
||||
expect(text.toLowerCase()).not.toContain("whoop");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — bounded Coach retrieval loop: tool dispatch round-trip, the
|
||||
* max-rounds cap with a forced-final answer, parallel tool execution, and the
|
||||
* summed-token accounting the budget reconcile depends on.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import type { CoachToolResult } from "@/lib/ai/coach/tools/executor";
|
||||
|
||||
const executeCoachTool = vi.fn<() => Promise<CoachToolResult>>();
|
||||
vi.mock("@/lib/ai/coach/tools/executor", () => ({
|
||||
executeCoachTool: () => executeCoachTool(),
|
||||
}));
|
||||
|
||||
// The loop calls the real fallback runner; stub it to a deterministic
|
||||
// per-round script so we exercise the loop control flow, not the chain.
|
||||
const runRawCompletionWithFallback = vi.fn();
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
runRawCompletionWithFallback: (args: unknown) =>
|
||||
runRawCompletionWithFallback(args),
|
||||
}));
|
||||
|
||||
import { runCoachToolLoop, HARD_CAP } from "@/lib/ai/coach/tools/loop";
|
||||
import { COACH_TOOL_DEFS } from "@/lib/ai/coach/tools/definitions";
|
||||
|
||||
function completion(opts: {
|
||||
content: string;
|
||||
tokensUsed?: number;
|
||||
toolCalls?: Array<{ id: string; name: string; arguments: string }>;
|
||||
finishReason?: "stop" | "tool_calls" | "length";
|
||||
}) {
|
||||
return {
|
||||
result: {
|
||||
content: opts.content,
|
||||
tokensUsed: opts.tokensUsed ?? 10,
|
||||
model: "mock",
|
||||
providerType: "anthropic" as const,
|
||||
...(opts.toolCalls ? { toolCalls: opts.toolCalls } : {}),
|
||||
finishReason: opts.finishReason,
|
||||
},
|
||||
workingProvider: { providerType: "anthropic" },
|
||||
fallbackHops: [],
|
||||
};
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
userId: "u1",
|
||||
providers: [],
|
||||
system: "sys",
|
||||
messages: [{ role: "user" as const, content: "how is my bp?" }],
|
||||
tools: COACH_TOOL_DEFS,
|
||||
};
|
||||
|
||||
describe("runCoachToolLoop", () => {
|
||||
beforeEach(() => {
|
||||
executeCoachTool.mockReset();
|
||||
runRawCompletionWithFallback.mockReset();
|
||||
});
|
||||
|
||||
it("runs one tool round then streams the final prose (happy path)", async () => {
|
||||
executeCoachTool.mockResolvedValue({ present: true, data: { x: 1 } });
|
||||
runRawCompletionWithFallback
|
||||
.mockResolvedValueOnce(
|
||||
completion({
|
||||
content: "",
|
||||
tokensUsed: 30,
|
||||
finishReason: "tool_calls",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "c1",
|
||||
name: "get_metric_series",
|
||||
arguments: '{"metric":"bp"}',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
completion({ content: "Your BP looks steady.", tokensUsed: 20 }),
|
||||
);
|
||||
|
||||
const out = await runCoachToolLoop(baseArgs);
|
||||
|
||||
expect(out.rounds).toBe(2);
|
||||
expect(out.result.content).toBe("Your BP looks steady.");
|
||||
expect(out.totalTokens).toBe(50); // summed across both rounds
|
||||
expect(out.toolTrace).toEqual([
|
||||
{ name: "get_metric_series", present: true },
|
||||
]);
|
||||
// The second call must forbid tools? No — round 2 still offers them
|
||||
// (round <= MAX_ROUNDS). It simply chose to answer.
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("answers immediately when the model emits no tool calls", async () => {
|
||||
runRawCompletionWithFallback.mockResolvedValueOnce(
|
||||
completion({ content: "Here's what I can help with.", tokensUsed: 12 }),
|
||||
);
|
||||
const out = await runCoachToolLoop(baseArgs);
|
||||
expect(out.rounds).toBe(1);
|
||||
expect(out.toolTrace).toHaveLength(0);
|
||||
expect(executeCoachTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("executes parallel tool calls in one round", async () => {
|
||||
executeCoachTool.mockResolvedValue({ present: true });
|
||||
runRawCompletionWithFallback
|
||||
.mockResolvedValueOnce(
|
||||
completion({
|
||||
content: "",
|
||||
finishReason: "tool_calls",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "a",
|
||||
name: "get_metric_series",
|
||||
arguments: '{"metric":"bp"}',
|
||||
},
|
||||
{ id: "b", name: "get_sleep", arguments: "{}" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(completion({ content: "Combined view." }));
|
||||
|
||||
const out = await runCoachToolLoop(baseArgs);
|
||||
expect(executeCoachTool).toHaveBeenCalledTimes(2);
|
||||
expect(out.toolTrace).toHaveLength(2);
|
||||
expect(out.result.content).toBe("Combined view.");
|
||||
});
|
||||
|
||||
it("forces a final answer at the hard cap (no infinite loop)", async () => {
|
||||
executeCoachTool.mockResolvedValue({ present: false });
|
||||
// The model keeps asking for tools every round. The loop must still
|
||||
// terminate: the last allowed round offers no tools (toolChoice none) so
|
||||
// the model is forced to produce prose.
|
||||
runRawCompletionWithFallback.mockImplementation(
|
||||
(args: { params: { toolChoice?: string } }) => {
|
||||
if (args.params.toolChoice === "none") {
|
||||
return Promise.resolve(
|
||||
completion({ content: "Final forced answer." }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(
|
||||
completion({
|
||||
content: "",
|
||||
finishReason: "tool_calls",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "x",
|
||||
name: "get_metric_series",
|
||||
arguments: '{"metric":"bp"}',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const out = await runCoachToolLoop(baseArgs);
|
||||
expect(out.rounds).toBe(HARD_CAP);
|
||||
expect(out.result.content).toBe("Final forced answer.");
|
||||
// The final round must have been called with toolChoice "none".
|
||||
const lastCall =
|
||||
runRawCompletionWithFallback.mock.calls[
|
||||
runRawCompletionWithFallback.mock.calls.length - 1
|
||||
][0];
|
||||
expect(lastCall.params.toolChoice).toBe("none");
|
||||
});
|
||||
|
||||
it("appends assistant(toolCalls) + tool turns to the message array", async () => {
|
||||
executeCoachTool.mockResolvedValue({ present: true, data: { v: 1 } });
|
||||
let secondCallMessages: unknown;
|
||||
runRawCompletionWithFallback
|
||||
.mockResolvedValueOnce(
|
||||
completion({
|
||||
content: "",
|
||||
finishReason: "tool_calls",
|
||||
toolCalls: [{ id: "c1", name: "get_sleep", arguments: "{}" }],
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce((args: { params: { messages: unknown } }) => {
|
||||
secondCallMessages = args.params.messages;
|
||||
return Promise.resolve(completion({ content: "done" }));
|
||||
});
|
||||
|
||||
await runCoachToolLoop(baseArgs);
|
||||
|
||||
const msgs = secondCallMessages as Array<{
|
||||
role: string;
|
||||
toolCallId?: string;
|
||||
toolCalls?: unknown[];
|
||||
}>;
|
||||
// user, assistant(toolCalls), tool
|
||||
expect(msgs).toHaveLength(3);
|
||||
expect(msgs[1].role).toBe("assistant");
|
||||
expect(msgs[1].toolCalls).toHaveLength(1);
|
||||
expect(msgs[2].role).toBe("tool");
|
||||
expect(msgs[2].toolCallId).toBe("c1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — every CoachScopeSource must either resolve to a snapshot
|
||||
* section key (so get_metric_series can fetch it) or be one of the
|
||||
* dedicated-tool sources. A new source added to the scope enum without a key
|
||||
* here would otherwise fall through to a fabricated "no data" answer for a
|
||||
* metric the user actually has — this guard fails the build first.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
COACH_SOURCE_SNAPSHOT_KEY,
|
||||
METRIC_SERIES_EXCLUDED_SOURCES,
|
||||
} from "@/lib/ai/coach/tools/source-keys";
|
||||
|
||||
describe("COACH_SOURCE_SNAPSHOT_KEY", () => {
|
||||
it("covers every CoachScopeSource (key map or dedicated tool)", () => {
|
||||
const uncovered = coachScopeSourceSchema.options.filter(
|
||||
(source) =>
|
||||
COACH_SOURCE_SNAPSHOT_KEY[source] === undefined &&
|
||||
!METRIC_SERIES_EXCLUDED_SOURCES.has(source),
|
||||
);
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
|
||||
it("never maps an excluded (dedicated-tool) source to a series key", () => {
|
||||
for (const source of METRIC_SERIES_EXCLUDED_SOURCES) {
|
||||
expect(COACH_SOURCE_SNAPSHOT_KEY[source]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — Coach retrieval tool catalogue.
|
||||
*
|
||||
* Replaces snapshot-stuffing with on-demand retrieval: the model is handed a
|
||||
* tiny DATA INVENTORY in the base context and pulls only the domains it needs
|
||||
* via these tools. Each tool is a THIN, READ-ONLY wrapper over the existing
|
||||
* server-authoritative snapshot builder (`buildCoachSnapshot`) — same numbers,
|
||||
* same module/cycle gates, same I/O. No tool ever accepts `userId` (it is
|
||||
* narrowed from the session in the executor), and every tool is read-only, so
|
||||
* there is no new mutation or egress surface.
|
||||
*
|
||||
* Six tools ship in v1.20.0 (the research's recommended slice):
|
||||
* 1. get_metric_series — BP / weight / pulse + the ~38 additive series
|
||||
* 2. get_glucose_panel — per-context daily means + the 30-day clinical panel
|
||||
* 3. get_sleep — per-night sleep + sleep-rhythm (debt + chronotype)
|
||||
* 4. get_medication_compliance — dose-weighted compliance + GLP-1
|
||||
* 5. get_labs — latest reading per biomarker (12 months)
|
||||
* 6. get_illness_recovery — restMode + active/resolved illnesses + recovery composites
|
||||
*
|
||||
* cycle / correlation / workouts are deferred to a later release.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { AiToolDef } from "@/lib/ai/types";
|
||||
import {
|
||||
coachScopeSourceSchema,
|
||||
coachScopeWindowSchema,
|
||||
} from "@/lib/ai/coach/types";
|
||||
|
||||
/** The closed set of tool names F1 ships. */
|
||||
export const COACH_TOOL_NAMES = [
|
||||
"get_metric_series",
|
||||
"get_glucose_panel",
|
||||
"get_sleep",
|
||||
"get_medication_compliance",
|
||||
"get_labs",
|
||||
"get_illness_recovery",
|
||||
] as const;
|
||||
|
||||
export type CoachToolName = (typeof COACH_TOOL_NAMES)[number];
|
||||
|
||||
export function isCoachToolName(name: string): name is CoachToolName {
|
||||
return (COACH_TOOL_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
// ── Per-tool argument schemas ────────────────────────────────────────
|
||||
// Closed enums + optional windows only — no free-text, no host, no id. The
|
||||
// executor `safeParse`s the model's raw JSON arguments against these before it
|
||||
// touches the snapshot builder, so a malformed / adversarial argument blob can
|
||||
// never widen scope or reach an un-validated read.
|
||||
|
||||
export const getMetricSeriesArgsSchema = z
|
||||
.object({
|
||||
metric: coachScopeSourceSchema,
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const getGlucosePanelArgsSchema = z
|
||||
.object({
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const getSleepArgsSchema = z
|
||||
.object({
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const getMedicationComplianceArgsSchema = z
|
||||
.object({
|
||||
window: coachScopeWindowSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const getLabsArgsSchema = z
|
||||
.object({
|
||||
/** Optional single-analyte filter (free-text biomarker name). */
|
||||
analyte: z.string().min(1).max(80).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const getIllnessRecoveryArgsSchema = z.object({}).strict();
|
||||
|
||||
/**
|
||||
* JSON-Schema parameter shapes handed to the provider. Kept hand-written
|
||||
* (rather than generated from Zod) so the wire description the model reads is
|
||||
* compact and stable — the byte-stable tool block keeps the cached prefix
|
||||
* intact across turns.
|
||||
*/
|
||||
const WINDOW_ENUM = [
|
||||
"last7days",
|
||||
"last30days",
|
||||
"last90days",
|
||||
"lastYear",
|
||||
"allTime",
|
||||
];
|
||||
|
||||
/**
|
||||
* The tool definitions offered to the model. Descriptions are deliberately
|
||||
* terse and brand-free; they tell the model WHEN to reach for each tool and
|
||||
* that an absent domain returns `{ present: false }` rather than an error.
|
||||
*/
|
||||
export const COACH_TOOL_DEFS: AiToolDef[] = [
|
||||
{
|
||||
name: "get_metric_series",
|
||||
description:
|
||||
"Fetch the user's own time series for ONE metric: blood pressure (bp), weight, pulse, or any synced series (hrv, resting_hr, steps, sleep duration, body composition, gait, audio exposure, vo2_max, …). Returns an aggregate plus a recent-daily and weekly timeline. Call once per metric you need; call several in parallel for a multi-metric question. Returns { present: false } when the user has no data for that metric.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["metric"],
|
||||
properties: {
|
||||
metric: {
|
||||
type: "string",
|
||||
enum: coachScopeSourceSchema.options,
|
||||
description: "The metric to fetch.",
|
||||
},
|
||||
window: {
|
||||
type: "string",
|
||||
enum: WINDOW_ENUM,
|
||||
description: "Analysis window. Defaults to the user's scope window.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_glucose_panel",
|
||||
description:
|
||||
"Fetch the user's glucose data: per-context daily means plus the trailing-30-day clinical panel (time-in-range, GMI, CV%, estimated A1c). Returns { present: false } when the user logs no glucose.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {
|
||||
window: {
|
||||
type: "string",
|
||||
enum: WINDOW_ENUM,
|
||||
description:
|
||||
"Window for the per-context means. The clinical panel is always the fixed trailing 30 days.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_sleep",
|
||||
description:
|
||||
"Fetch the user's sleep: per-night asleep + stage minutes plus the sleep-rhythm summary (sleep debt + chronotype). Returns { present: false } when no sleep is tracked.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {
|
||||
window: {
|
||||
type: "string",
|
||||
enum: WINDOW_ENUM,
|
||||
description: "Analysis window. Defaults to the user's scope window.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_medication_compliance",
|
||||
description:
|
||||
"Fetch the user's medication compliance: the dose-weighted adherence rate plus a recent timeline, and any GLP-1 titration context. Returns { present: false } when no medications are tracked.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {
|
||||
window: {
|
||||
type: "string",
|
||||
enum: WINDOW_ENUM,
|
||||
description: "Analysis window. Defaults to the user's scope window.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_labs",
|
||||
description:
|
||||
"Fetch the user's most recent lab results — the latest reading per biomarker over the last 12 months, or one named analyte. Returns { present: false } when no labs are on file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {
|
||||
analyte: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional single biomarker name to filter to (e.g. 'LDL').",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_illness_recovery",
|
||||
description:
|
||||
"Fetch the user's illness + recovery context: rest mode, active and recently-resolved illnesses, and the recovery / strain composites. Returns { present: false } when there is nothing to report.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — Coach retrieval tool executor.
|
||||
*
|
||||
* Dispatches a single tool call from the model: validates the raw JSON
|
||||
* arguments against the tool's Zod schema, runs the existing
|
||||
* server-authoritative snapshot builder scoped to just the requested domain,
|
||||
* and slices the matching section(s) out of the structured result.
|
||||
*
|
||||
* GROUNDING CONTRACTS (the hallucination audit hammers these):
|
||||
* - Every tool returns a structured `{ present: boolean }`. When the domain
|
||||
* carries no data — or the user opted out of the owning module — the result
|
||||
* is `{ present: false, reason }`. It NEVER throws and NEVER returns an
|
||||
* ambiguous `[]`, so the model can always tell "no data" from "data".
|
||||
* - Module / cycle gates are enforced AT THE BUILDER (`buildCoachSnapshot`
|
||||
* applies `MODULE_EXCLUDED_SOURCES` + the cycle gate before any row is
|
||||
* read), so an opted-out domain is structurally unfetchable here — the
|
||||
* section simply never appears and the executor reports `present: false`.
|
||||
* - `userId` is the session-narrowed id passed by the route, never a tool
|
||||
* argument. Tools are read-only (snapshot reads only), so there is no
|
||||
* mutation or egress surface.
|
||||
*
|
||||
* The builder result is memoised by the 60s snapshot LRU keyed on
|
||||
* `(userId, window, sources)`, so several tool calls in one turn (and the
|
||||
* base-context inventory build) share the underlying reads.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import type { CoachScope, CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import {
|
||||
getMetricSeriesArgsSchema,
|
||||
getGlucosePanelArgsSchema,
|
||||
getSleepArgsSchema,
|
||||
getMedicationComplianceArgsSchema,
|
||||
getLabsArgsSchema,
|
||||
getIllnessRecoveryArgsSchema,
|
||||
isCoachToolName,
|
||||
type CoachToolName,
|
||||
} from "./definitions";
|
||||
import {
|
||||
COACH_SOURCE_SNAPSHOT_KEY,
|
||||
METRIC_SERIES_EXCLUDED_SOURCES,
|
||||
} from "./source-keys";
|
||||
|
||||
/** A read-only structured tool result. Serialised to a `role:"tool"` turn. */
|
||||
export interface CoachToolResult {
|
||||
present: boolean;
|
||||
/** Short machine reason on a `present: false` result (no PII). */
|
||||
reason?: string;
|
||||
/** The domain payload on a `present: true` result (the snapshot section). */
|
||||
data?: unknown;
|
||||
/**
|
||||
* Optional citation-coupled reference grounding for the fetched metric —
|
||||
* published population bands + the user's placement, general guidance only.
|
||||
*/
|
||||
grounding?: string;
|
||||
}
|
||||
|
||||
/** What the route persists onto provenance: which tools ran, did data exist. */
|
||||
export interface CoachToolTrace {
|
||||
name: string;
|
||||
present: boolean;
|
||||
}
|
||||
|
||||
function pickSection(
|
||||
sections: Record<string, unknown>,
|
||||
key: string,
|
||||
): unknown | undefined {
|
||||
const value = sections[key];
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `args.window` into a scope. Falls back to the conversation's
|
||||
* effective window so a tool call that omits `window` matches the user's
|
||||
* chosen analysis window rather than the builder default.
|
||||
*/
|
||||
function scopeFor(
|
||||
sources: CoachScope["sources"],
|
||||
window: CoachScopeWindow | undefined,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): CoachScope {
|
||||
return {
|
||||
sources,
|
||||
window: window ?? fallbackWindow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call. `rawArguments` is the model's raw JSON-string
|
||||
* arguments (parsed + validated here, never trusted blindly). Returns a
|
||||
* grounded `CoachToolResult` — a validation failure or unknown tool resolves
|
||||
* to `{ present: false }`, never a throw, so a single bad call can't break the
|
||||
* turn.
|
||||
*/
|
||||
export async function executeCoachTool(args: {
|
||||
userId: string;
|
||||
name: string;
|
||||
rawArguments: string;
|
||||
/** The conversation's effective window, used when a call omits `window`. */
|
||||
fallbackWindow?: CoachScopeWindow;
|
||||
}): Promise<CoachToolResult> {
|
||||
const { userId, name, rawArguments, fallbackWindow } = args;
|
||||
|
||||
if (!isCoachToolName(name)) {
|
||||
annotate({
|
||||
action: { name: "coach.tool.unknown" },
|
||||
meta: { tool: name.slice(0, 48) },
|
||||
});
|
||||
return { present: false, reason: "unknown_tool" };
|
||||
}
|
||||
|
||||
let parsedArgs: unknown;
|
||||
try {
|
||||
parsedArgs = rawArguments.trim() === "" ? {} : JSON.parse(rawArguments);
|
||||
} catch {
|
||||
annotate({
|
||||
action: { name: "coach.tool.bad_arguments" },
|
||||
meta: { tool: name, reason: "invalid_json" },
|
||||
});
|
||||
return { present: false, reason: "invalid_arguments" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await dispatch(name, userId, parsedArgs, fallbackWindow);
|
||||
annotate({
|
||||
action: { name: "coach.tool.executed" },
|
||||
meta: { tool: name, present: result.present },
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
// A read failure must degrade to a grounded "no data" rather than break
|
||||
// the loop — the model then says it could not retrieve the metric.
|
||||
annotate({
|
||||
action: { name: "coach.tool.error" },
|
||||
meta: {
|
||||
tool: name,
|
||||
reason: err instanceof Error ? err.name : "unknown",
|
||||
},
|
||||
});
|
||||
return { present: false, reason: "retrieval_failed" };
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
name: CoachToolName,
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
switch (name) {
|
||||
case "get_metric_series":
|
||||
return getMetricSeries(userId, rawArgs, fallbackWindow);
|
||||
case "get_glucose_panel":
|
||||
return getGlucosePanel(userId, rawArgs, fallbackWindow);
|
||||
case "get_sleep":
|
||||
return getSleep(userId, rawArgs, fallbackWindow);
|
||||
case "get_medication_compliance":
|
||||
return getMedicationCompliance(userId, rawArgs, fallbackWindow);
|
||||
case "get_labs":
|
||||
return getLabs(userId, rawArgs);
|
||||
case "get_illness_recovery":
|
||||
return getIllnessRecovery(userId);
|
||||
}
|
||||
}
|
||||
|
||||
function badArgs(name: string, error: z.ZodError): CoachToolResult {
|
||||
annotate({
|
||||
action: { name: "coach.tool.bad_arguments" },
|
||||
meta: { tool: name, reason: "schema", issues: error.issues.length },
|
||||
});
|
||||
return { present: false, reason: "invalid_arguments" };
|
||||
}
|
||||
|
||||
async function getMetricSeries(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getMetricSeriesArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_metric_series", parsed.error);
|
||||
const { metric, window } = parsed.data;
|
||||
|
||||
// Glucose / workouts / compliance have dedicated tools (or are deferred);
|
||||
// refuse to answer them here so the model is pointed at the right tool
|
||||
// rather than getting a fabricated-shape miss.
|
||||
if (METRIC_SERIES_EXCLUDED_SOURCES.has(metric)) {
|
||||
return {
|
||||
present: false,
|
||||
reason:
|
||||
metric === "glucose"
|
||||
? "use_get_glucose_panel"
|
||||
: metric === "compliance"
|
||||
? "use_get_medication_compliance"
|
||||
: "unsupported_metric",
|
||||
};
|
||||
}
|
||||
|
||||
const sectionKey = COACH_SOURCE_SNAPSHOT_KEY[metric];
|
||||
if (!sectionKey) {
|
||||
return { present: false, reason: "unsupported_metric" };
|
||||
}
|
||||
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor([metric], window, fallbackWindow),
|
||||
);
|
||||
const section = pickSection(snapshot.sections, sectionKey);
|
||||
if (section === undefined) {
|
||||
return { present: false, reason: "no_data" };
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: { metric, section },
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function getGlucosePanel(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getGlucosePanelArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_glucose_panel", parsed.error);
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["glucose"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
const section = pickSection(snapshot.sections, "glucose");
|
||||
if (section === undefined) return { present: false, reason: "no_data" };
|
||||
return {
|
||||
present: true,
|
||||
data: section,
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSleep(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getSleepArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_sleep", parsed.error);
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["sleep"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
const nights = pickSection(snapshot.sections, "sleep");
|
||||
const rhythm = pickSection(snapshot.sections, "sleepRhythm");
|
||||
if (nights === undefined && rhythm === undefined) {
|
||||
return { present: false, reason: "no_data" };
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(nights !== undefined ? { nights } : {}),
|
||||
...(rhythm !== undefined ? { rhythm } : {}),
|
||||
},
|
||||
grounding: snapshot.referenceGrounding ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function getMedicationCompliance(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
fallbackWindow: CoachScopeWindow | undefined,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getMedicationComplianceArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
return badArgs("get_medication_compliance", parsed.error);
|
||||
}
|
||||
const snapshot = await buildCoachSnapshot(
|
||||
userId,
|
||||
scopeFor(["compliance"], parsed.data.window, fallbackWindow),
|
||||
);
|
||||
const compliance = pickSection(snapshot.sections, "compliance");
|
||||
// GLP-1 context rides the `weeklyContext` block.
|
||||
const weeklyContext = pickSection(snapshot.sections, "weeklyContext") as
|
||||
| { glp1?: unknown }
|
||||
| undefined;
|
||||
const glp1 = weeklyContext?.glp1;
|
||||
if (compliance === undefined && glp1 === undefined) {
|
||||
return { present: false, reason: "no_data" };
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(compliance !== undefined ? { compliance } : {}),
|
||||
...(glp1 !== undefined ? { glp1 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getLabs(
|
||||
userId: string,
|
||||
rawArgs: unknown,
|
||||
): Promise<CoachToolResult> {
|
||||
const parsed = getLabsArgsSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) return badArgs("get_labs", parsed.error);
|
||||
// Labs ride the snapshot regardless of `sources` (attached unconditionally),
|
||||
// so a minimal scope still surfaces them. Pass an empty source set to keep
|
||||
// the read tight.
|
||||
const snapshot = await buildCoachSnapshot(userId, { sources: [] });
|
||||
const labs = pickSection(snapshot.sections, "labs") as
|
||||
| { recent?: Array<{ name?: string; analyte?: string }> }
|
||||
| undefined;
|
||||
if (labs === undefined) return { present: false, reason: "no_data" };
|
||||
|
||||
const analyte = parsed.data.analyte?.trim().toLowerCase();
|
||||
if (analyte && Array.isArray(labs.recent)) {
|
||||
const filtered = labs.recent.filter((entry) => {
|
||||
const haystack =
|
||||
`${entry.name ?? ""} ${entry.analyte ?? ""}`.toLowerCase();
|
||||
return haystack.includes(analyte);
|
||||
});
|
||||
if (filtered.length === 0) {
|
||||
return { present: false, reason: "analyte_not_found" };
|
||||
}
|
||||
return { present: true, data: { recent: filtered } };
|
||||
}
|
||||
return { present: true, data: labs };
|
||||
}
|
||||
|
||||
async function getIllnessRecovery(userId: string): Promise<CoachToolResult> {
|
||||
// Validate the (empty) args shape for consistency; an empty object always
|
||||
// passes.
|
||||
getIllnessRecoveryArgsSchema.parse({});
|
||||
// Recovery composites gate on the `recovery` module + the HRV/RHR/VO2max
|
||||
// sources; request them so the derived / dayStrain / trajectory blocks can
|
||||
// build when the module is on. Illness rides the snapshot unconditionally.
|
||||
const snapshot = await buildCoachSnapshot(userId, {
|
||||
sources: ["hrv", "resting_hr", "vo2_max"],
|
||||
});
|
||||
const illness = pickSection(snapshot.sections, "illness");
|
||||
const derived = pickSection(snapshot.sections, "derived");
|
||||
const dayStrain = pickSection(snapshot.sections, "dayStrain");
|
||||
const trajectory = pickSection(snapshot.sections, "trajectory");
|
||||
if (
|
||||
illness === undefined &&
|
||||
derived === undefined &&
|
||||
dayStrain === undefined &&
|
||||
trajectory === undefined
|
||||
) {
|
||||
return { present: false, reason: "no_data" };
|
||||
}
|
||||
return {
|
||||
present: true,
|
||||
data: {
|
||||
...(illness !== undefined ? { illness } : {}),
|
||||
...(derived !== undefined ? { derived } : {}),
|
||||
...(dayStrain !== undefined ? { dayStrain } : {}),
|
||||
...(trajectory !== undefined ? { trajectory } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — Coach retrieval-tool barrel.
|
||||
*
|
||||
* The on-demand retrieval slice that replaces snapshot-stuffing: a tiny DATA
|
||||
* INVENTORY in the base context, six read-only tools the model calls to fetch
|
||||
* figures, and a bounded loop that executes them. The legacy snapshot path
|
||||
* stays alive as the no-tools fallback (per-provider `supportsTools`).
|
||||
*/
|
||||
export {
|
||||
COACH_TOOL_DEFS,
|
||||
COACH_TOOL_NAMES,
|
||||
isCoachToolName,
|
||||
type CoachToolName,
|
||||
} from "./definitions";
|
||||
export {
|
||||
executeCoachTool,
|
||||
type CoachToolResult,
|
||||
type CoachToolTrace,
|
||||
} from "./executor";
|
||||
export {
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
type CoachDataInventory,
|
||||
type InventoryEntry,
|
||||
} from "./inventory";
|
||||
export { buildToolModeAddendum } from "./system-addendum";
|
||||
export {
|
||||
runCoachToolLoop,
|
||||
MAX_ROUNDS,
|
||||
HARD_CAP,
|
||||
type CoachToolLoopResult,
|
||||
} from "./loop";
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — Coach DATA INVENTORY manifest.
|
||||
*
|
||||
* The tiny, always-sent manifest that replaces snapshot-stuffing in the base
|
||||
* context. It tells the model WHAT data exists (so it never invents a metric)
|
||||
* and WHICH tool to call to fetch it — without shipping the figures themselves.
|
||||
*
|
||||
* Built deterministically from a single `buildCoachSnapshot` over the user's
|
||||
* effective scope: the snapshot already resolves the module / cycle gates and
|
||||
* computes presence + counts, so a domain the user opted out of is structurally
|
||||
* absent from the manifest (it will report `present: false`). The build is
|
||||
* memoised by the 60s snapshot LRU, so the tools that fire on the same turn
|
||||
* re-use these reads rather than paying for them twice.
|
||||
*
|
||||
* Cost: the manifest is ~0.3–0.5k tokens vs the ~6–15k-token snapshot JSON it
|
||||
* replaces — the figures move out of the prompt and into on-demand tool results.
|
||||
*/
|
||||
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import type { CoachProvenanceMetric, CoachScope } from "@/lib/ai/coach/types";
|
||||
|
||||
/** One row of the inventory: a domain + whether the user has data for it. */
|
||||
export interface InventoryEntry {
|
||||
/** The tool that fetches this domain. */
|
||||
tool: string;
|
||||
/** Stable domain label the model reads (e.g. "blood pressure", "glucose"). */
|
||||
domain: string;
|
||||
present: boolean;
|
||||
/** Sample count, when the snapshot reported one. */
|
||||
count?: number;
|
||||
/** For get_metric_series rows: the `metric` argument to pass. */
|
||||
metric?: string;
|
||||
}
|
||||
|
||||
export interface CoachDataInventory {
|
||||
entries: InventoryEntry[];
|
||||
/** Illness rest-mode flag, so the safety framing is right before any call. */
|
||||
restMode: boolean;
|
||||
/** Whether cycle tracking is available (the get_cycle tool is deferred). */
|
||||
cycleEnabled: boolean;
|
||||
/** The window the inventory was built against. */
|
||||
window: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a snapshot section key → an inventory row. The per-metric series tool
|
||||
* carries the `metric` argument; the dedicated-tool domains name their tool.
|
||||
*/
|
||||
const METRIC_SERIES_DOMAINS: Array<{
|
||||
sectionKey: string;
|
||||
metric: string;
|
||||
domain: string;
|
||||
provenance: CoachProvenanceMetric;
|
||||
}> = [
|
||||
{
|
||||
sectionKey: "bloodPressure",
|
||||
metric: "bp",
|
||||
domain: "blood pressure",
|
||||
provenance: "bp",
|
||||
},
|
||||
{
|
||||
sectionKey: "weight",
|
||||
metric: "weight",
|
||||
domain: "weight",
|
||||
provenance: "weight",
|
||||
},
|
||||
{
|
||||
sectionKey: "pulse",
|
||||
metric: "pulse",
|
||||
domain: "pulse",
|
||||
provenance: "pulse",
|
||||
},
|
||||
{ sectionKey: "mood", metric: "mood", domain: "mood", provenance: "mood" },
|
||||
{
|
||||
sectionKey: "heartRateVariability",
|
||||
metric: "hrv",
|
||||
domain: "heart-rate variability",
|
||||
provenance: "hrv",
|
||||
},
|
||||
{
|
||||
sectionKey: "restingHeartRate",
|
||||
metric: "resting_hr",
|
||||
domain: "resting heart rate",
|
||||
provenance: "resting_hr",
|
||||
},
|
||||
{
|
||||
sectionKey: "steps",
|
||||
metric: "steps",
|
||||
domain: "steps",
|
||||
provenance: "steps",
|
||||
},
|
||||
{
|
||||
sectionKey: "vo2Max",
|
||||
metric: "vo2_max",
|
||||
domain: "VO2 max",
|
||||
provenance: "vo2_max",
|
||||
},
|
||||
];
|
||||
|
||||
export async function buildCoachDataInventory(
|
||||
userId: string,
|
||||
scope: CoachScope | undefined,
|
||||
): Promise<CoachDataInventory> {
|
||||
const [snapshot, cycleEnabled] = await Promise.all([
|
||||
buildCoachSnapshot(userId, scope),
|
||||
isCycleAvailableForUser(userId),
|
||||
]);
|
||||
const sections = snapshot.sections;
|
||||
const counts = snapshot.provenance.counts ?? {};
|
||||
const has = (key: string): boolean =>
|
||||
sections[key] !== undefined && sections[key] !== null;
|
||||
|
||||
const entries: InventoryEntry[] = [];
|
||||
|
||||
// get_metric_series domains — one row per metric the snapshot surfaced.
|
||||
for (const m of METRIC_SERIES_DOMAINS) {
|
||||
const present = has(m.sectionKey);
|
||||
entries.push({
|
||||
tool: "get_metric_series",
|
||||
metric: m.metric,
|
||||
domain: m.domain,
|
||||
present,
|
||||
...(present && typeof counts[m.provenance] === "number"
|
||||
? { count: counts[m.provenance] }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Dedicated-tool domains.
|
||||
entries.push({
|
||||
tool: "get_glucose_panel",
|
||||
domain: "glucose",
|
||||
present: has("glucose"),
|
||||
...(typeof counts.glucose === "number" ? { count: counts.glucose } : {}),
|
||||
});
|
||||
entries.push({
|
||||
tool: "get_sleep",
|
||||
domain: "sleep",
|
||||
present: has("sleep") || has("sleepRhythm"),
|
||||
...(typeof counts.sleep === "number" ? { count: counts.sleep } : {}),
|
||||
});
|
||||
entries.push({
|
||||
tool: "get_medication_compliance",
|
||||
domain: "medication compliance",
|
||||
present: has("compliance") || has("weeklyContext"),
|
||||
...(typeof counts.compliance === "number"
|
||||
? { count: counts.compliance }
|
||||
: {}),
|
||||
});
|
||||
entries.push({
|
||||
tool: "get_labs",
|
||||
domain: "lab results",
|
||||
present: has("labs"),
|
||||
});
|
||||
entries.push({
|
||||
tool: "get_illness_recovery",
|
||||
domain: "illness & recovery",
|
||||
present:
|
||||
has("illness") || has("derived") || has("dayStrain") || has("trajectory"),
|
||||
});
|
||||
|
||||
// restMode rides the illness section.
|
||||
const illness = sections.illness as { restMode?: boolean } | undefined;
|
||||
const restMode = illness?.restMode === true;
|
||||
|
||||
const scopeBlock = sections.scope as { window?: string } | undefined;
|
||||
|
||||
return {
|
||||
entries,
|
||||
restMode,
|
||||
cycleEnabled,
|
||||
window: scopeBlock?.window ?? scope?.window ?? "last30days",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the inventory as the compact text block that rides the base context.
|
||||
* Brand-free, deterministic, and stable across turns so the prompt-cache prefix
|
||||
* stays intact. Each line tells the model the domain, whether data exists, and
|
||||
* the tool + argument to call.
|
||||
*/
|
||||
export function renderDataInventory(inventory: CoachDataInventory): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("DATA INVENTORY");
|
||||
lines.push(
|
||||
"This lists which of the user's data exists and how to fetch it. Call the named tool ONLY for a domain marked present. Never cite a figure you did not fetch with a tool this turn; when a tool returns present:false, say plainly you have no data for it and pivot.",
|
||||
);
|
||||
lines.push(`Window: ${inventory.window}.`);
|
||||
if (inventory.restMode) {
|
||||
lines.push(
|
||||
"The user is currently in REST MODE (recovering from illness) — frame any activity guidance gently.",
|
||||
);
|
||||
}
|
||||
for (const e of inventory.entries) {
|
||||
const arg = e.metric ? ` (metric:"${e.metric}")` : "";
|
||||
lines.push(
|
||||
`- ${e.domain}: ${e.present ? "present" : "absent"} → ${e.tool}${arg}${
|
||||
e.present && typeof e.count === "number" ? ` [~${e.count} samples]` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — bounded Coach retrieval loop.
|
||||
*
|
||||
* Runs the agentic call → tool-result → answer cycle on top of the existing
|
||||
* `runRawCompletionWithFallback` chain. Each round is a NON-streaming
|
||||
* completion (the chain returns a whole body); the route fake-tokenises the
|
||||
* FINAL prose for the visible stream exactly as before.
|
||||
*
|
||||
* Bounds (the loop can never run away):
|
||||
* - MAX_ROUNDS = 2: one tool-fetch round + the answer round.
|
||||
* - HARD_CAP = 3: an absolute ceiling; on the last allowed round the loop
|
||||
* re-calls with `toolChoice:"none"` so the model is FORCED to produce prose
|
||||
* even if it would otherwise keep asking for tools.
|
||||
* - Tool calls within a round run in parallel (`Promise.all`).
|
||||
*
|
||||
* Token accounting: the caller reserves `maxTokens * MAX_ROUNDS` up front and
|
||||
* reconciles the SUMMED `tokensUsed` this function returns. The atomic
|
||||
* reserve/reconcile primitives are unchanged.
|
||||
*
|
||||
* Provider fallback: when the working provider does not support tools
|
||||
* (`supportsTools === false`) or returns no tool calls, the loop degrades to a
|
||||
* single completion — the caller falls back to the snapshot path BEFORE
|
||||
* entering this loop, so this is the safety floor, not the primary route.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { runRawCompletionWithFallback } from "@/lib/ai/provider-runner";
|
||||
import type { ProviderChainResolved } from "@/lib/ai/provider-runner";
|
||||
import type { AiMessage, AiToolDef, CompletionResult } from "@/lib/ai/types";
|
||||
import type { ProviderHealthLedger } from "@/lib/ai/provider-health-ledger";
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { executeCoachTool, type CoachToolTrace } from "./executor";
|
||||
|
||||
export const MAX_ROUNDS = 2;
|
||||
/** Absolute ceiling; the final allowed round forces prose (toolChoice none). */
|
||||
export const HARD_CAP = 3;
|
||||
|
||||
export interface CoachToolLoopResult {
|
||||
/** The final completion (prose). */
|
||||
result: CompletionResult;
|
||||
/** The working provider's type tag. */
|
||||
workingProviderType: string;
|
||||
/** Summed tokens across every round (for budget reconcile). */
|
||||
totalTokens: number;
|
||||
/** Number of model round-trips made. */
|
||||
rounds: number;
|
||||
/** Which tools ran + whether each found data (persisted onto provenance). */
|
||||
toolTrace: CoachToolTrace[];
|
||||
}
|
||||
|
||||
export async function runCoachToolLoop(args: {
|
||||
userId: string;
|
||||
providers: ProviderChainResolved[];
|
||||
system: string;
|
||||
/** The conversation messages (history + the new user turn). */
|
||||
messages: AiMessage[];
|
||||
tools: AiToolDef[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
fallbackWindow?: CoachScopeWindow;
|
||||
ledger?: ProviderHealthLedger;
|
||||
}): Promise<CoachToolLoopResult> {
|
||||
const {
|
||||
userId,
|
||||
providers,
|
||||
system,
|
||||
tools,
|
||||
temperature,
|
||||
maxTokens,
|
||||
fallbackWindow,
|
||||
ledger,
|
||||
} = args;
|
||||
|
||||
const messages: AiMessage[] = [...args.messages];
|
||||
let totalTokens = 0;
|
||||
let rounds = 0;
|
||||
let workingProviderType = "";
|
||||
const toolTrace: CoachToolTrace[] = [];
|
||||
|
||||
// Round budget: rounds 1..HARD_CAP. On the last allowed round we forbid tool
|
||||
// calls so the model must answer.
|
||||
for (let round = 1; round <= HARD_CAP; round += 1) {
|
||||
rounds = round;
|
||||
const isForcedFinal = round >= HARD_CAP;
|
||||
const offerTools = round <= MAX_ROUNDS && !isForcedFinal;
|
||||
|
||||
const fallback = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers,
|
||||
ledger,
|
||||
params: {
|
||||
system,
|
||||
messages,
|
||||
temperature,
|
||||
maxTokens,
|
||||
...(offerTools
|
||||
? { tools, toolChoice: "auto" as const }
|
||||
: { toolChoice: "none" as const }),
|
||||
},
|
||||
});
|
||||
const result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
totalTokens += result.tokensUsed ?? 0;
|
||||
|
||||
const calls = result.toolCalls ?? [];
|
||||
const wantsTools =
|
||||
offerTools && result.finishReason === "tool_calls" && calls.length > 0;
|
||||
|
||||
if (!wantsTools) {
|
||||
// The model produced prose (or a no-tools provider returned empty
|
||||
// toolCalls) — we are done.
|
||||
annotate({
|
||||
action: { name: "coach.tool.rounds" },
|
||||
meta: {
|
||||
rounds,
|
||||
tools: toolTrace.length,
|
||||
forcedFinal: isForcedFinal,
|
||||
},
|
||||
});
|
||||
return { result, workingProviderType, totalTokens, rounds, toolTrace };
|
||||
}
|
||||
|
||||
// Append the assistant turn that requested the tools, then execute them in
|
||||
// parallel and append one tool-result turn per call.
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: result.content ?? "",
|
||||
toolCalls: calls,
|
||||
});
|
||||
|
||||
const results = await Promise.all(
|
||||
calls.map(async (call) => {
|
||||
const toolResult = await executeCoachTool({
|
||||
userId,
|
||||
name: call.name,
|
||||
rawArguments: call.arguments,
|
||||
fallbackWindow,
|
||||
});
|
||||
toolTrace.push({ name: call.name, present: toolResult.present });
|
||||
return { call, toolResult };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const { call, toolResult } of results) {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
toolCallId: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable: the forced-final round (round === HARD_CAP) always returns
|
||||
// above because `offerTools` is false, so `wantsTools` is false. Kept as a
|
||||
// defensive guard.
|
||||
throw new Error("coach tool loop exceeded hard cap without a final answer");
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — CoachScopeSource → snapshot section key map.
|
||||
*
|
||||
* `get_metric_series` fetches a single metric by re-running the snapshot
|
||||
* builder scoped to that one source, then reads the matching section out of the
|
||||
* structured `CoachSnapshotResult.sections`. The builder names each section by
|
||||
* a camelCase key (e.g. `heartRateVariability`); this map mirrors the
|
||||
* `valueBlocks` table in `snapshot.ts` plus the three core clinical blocks
|
||||
* (bloodPressure / weight / pulse) and the mood block.
|
||||
*
|
||||
* `glucose` and `workouts` are intentionally absent: they have dedicated tools
|
||||
* / branches (glucose carries the clinical panel; workouts is deferred). A
|
||||
* `get_metric_series` call for one of them resolves to `null` here and the
|
||||
* executor returns `{ present: false }` with a pointer to the right tool.
|
||||
*
|
||||
* The `source-keys.test.ts` guard asserts every `coachScopeSourceSchema` value
|
||||
* either resolves here or is explicitly one of the dedicated-tool sources, so a
|
||||
* future source addition cannot silently fall through to a fabricated answer.
|
||||
*/
|
||||
import type { CoachScopeSource } from "@/lib/ai/coach/types";
|
||||
|
||||
/** Sources that `get_metric_series` does NOT serve (they have own tools). */
|
||||
export const METRIC_SERIES_EXCLUDED_SOURCES: ReadonlySet<CoachScopeSource> =
|
||||
new Set<CoachScopeSource>(["glucose", "workouts", "compliance"]);
|
||||
|
||||
/** source → the snapshot section key its single-metric block lands under. */
|
||||
export const COACH_SOURCE_SNAPSHOT_KEY: Readonly<
|
||||
Partial<Record<CoachScopeSource, string>>
|
||||
> = {
|
||||
// ── core clinical ──
|
||||
bp: "bloodPressure",
|
||||
weight: "weight",
|
||||
pulse: "pulse",
|
||||
mood: "mood",
|
||||
// ── cardio ──
|
||||
hrv: "heartRateVariability",
|
||||
resting_hr: "restingHeartRate",
|
||||
walking_hr: "walkingHeartRateAverage",
|
||||
respiratory_rate: "respiratoryRate",
|
||||
spo2: "oxygenSaturation",
|
||||
pulse_wave_velocity: "pulseWaveVelocity",
|
||||
vascular_age: "vascularAge",
|
||||
// ── body composition ──
|
||||
body_fat: "bodyFat",
|
||||
fat_mass: "fatMass",
|
||||
fat_free_mass: "fatFreeMass",
|
||||
muscle_mass: "muscleMass",
|
||||
lean_body_mass: "leanBodyMass",
|
||||
bone_mass: "boneMass",
|
||||
total_body_water: "totalBodyWater",
|
||||
bmi: "bodyMassIndex",
|
||||
visceral_fat: "visceralFat",
|
||||
// ── activity ──
|
||||
steps: "steps",
|
||||
active_energy: "activeEnergy",
|
||||
flights: "flightsClimbed",
|
||||
distance: "walkingRunningDistance",
|
||||
vo2_max: "vo2Max",
|
||||
// ── mobility & gait ──
|
||||
walking_steadiness: "walkingSteadiness",
|
||||
walking_asymmetry: "walkingAsymmetry",
|
||||
walking_double_support: "walkingDoubleSupport",
|
||||
walking_step_length: "walkingStepLength",
|
||||
walking_speed: "walkingSpeed",
|
||||
// ── environment / exposure ──
|
||||
audio_env: "audioExposureEnvironment",
|
||||
audio_headphone: "audioExposureHeadphone",
|
||||
audio_event: "audioExposureEvent",
|
||||
daylight: "timeInDaylight",
|
||||
skin_temp: "skinTemperature",
|
||||
// ── sleep (also has the dedicated get_sleep tool, but the raw duration
|
||||
// series is reachable here too) ──
|
||||
sleep: "sleep",
|
||||
body_temp: "bodyTemperature",
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* v1.20.0 (F1) — tool-mode system-prompt addendum.
|
||||
*
|
||||
* Appended to the stable Coach system prompt ONLY when retrieval tools are
|
||||
* offered this turn (a tool-capable provider). It reframes the grounding
|
||||
* contract from "cite the SNAPSHOT" to "cite ONLY this turn's tool results",
|
||||
* and pins the empty-result rule the hallucination audit checks.
|
||||
*
|
||||
* Kept as a separate appended block (rather than editing the giant bilingual
|
||||
* base prompt) so the base prompt stays byte-identical — preserving the
|
||||
* provider prompt-cache prefix and the system-prompt contract-parity tests —
|
||||
* while the tool grounding rules ride a small, stable suffix.
|
||||
*
|
||||
* When tools are NOT offered (local / no-tools provider) this block is omitted
|
||||
* and the legacy snapshot-stuffing path runs with the unchanged prompt.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const EN = `TOOL-BASED RETRIEVAL (this conversation)
|
||||
|
||||
You have read-only retrieval tools and a DATA INVENTORY listing what the user has logged. The figures are NOT in this prompt — fetch them.
|
||||
|
||||
1. GROUNDING (overrides ground rule 7 for this turn): ground EVERY number you cite in a TOOL RESULT you received THIS turn. Never cite a figure you did not just fetch, never recall a number from earlier turns as if it were fresh, and never invent one.
|
||||
2. Call a tool ONLY for a domain the DATA INVENTORY marks "present". Call several tools in one step when a question spans metrics — they run in parallel.
|
||||
3. When a tool returns { present: false } or an empty result, say plainly that you have no data for that metric and pivot — do NOT infer, estimate, or fabricate a value.
|
||||
4. If you can answer without data (a definition, a "what can you help with?"), just answer — do not call a tool.
|
||||
5. Keep using the EVIDENCE (---KEYVALUES---) block exactly as before, citing only numbers you fetched this turn.`;
|
||||
|
||||
const DE = `TOOL-BASIERTE ABFRAGE (diese Unterhaltung)
|
||||
|
||||
Du hast schreibgeschützte Abfrage-Tools und ein DATA INVENTORY, das auflistet, was der Nutzer erfasst hat. Die Zahlen stehen NICHT in diesem Prompt — rufe sie ab.
|
||||
|
||||
1. VERANKERUNG (ersetzt Grundregel 7 für diesen Zug): verankere JEDE genannte Zahl in einem TOOL-ERGEBNIS, das du in DIESEM Zug erhalten hast. Nenne keine Zahl, die du nicht gerade abgerufen hast, gib keine Zahl aus früheren Zügen als frisch aus und erfinde keine.
|
||||
2. Rufe ein Tool NUR für eine Domäne auf, die das DATA INVENTORY als „present" markiert. Rufe mehrere Tools in einem Schritt auf, wenn eine Frage mehrere Metriken betrifft — sie laufen parallel.
|
||||
3. Wenn ein Tool { present: false } oder ein leeres Ergebnis liefert, sage klar, dass dazu keine Daten vorliegen, und lenke um — leite nichts ab, schätze nichts und erfinde keinen Wert.
|
||||
4. Wenn du ohne Daten antworten kannst (eine Definition, „Wobei kannst du helfen?"), antworte einfach — rufe kein Tool auf.
|
||||
5. Nutze den EVIDENZ-Block (---KEYVALUES---) genau wie zuvor und zitiere nur Zahlen, die du in diesem Zug abgerufen hast.`;
|
||||
|
||||
export function buildToolModeAddendum(locale: Locale): string {
|
||||
return locale === "de" ? DE : EN;
|
||||
}
|
||||
Reference in new issue
Block a user