mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -66,6 +66,17 @@ vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/coach/types", () => ({
|
||||
coachChatRequestSchema: { safeParse: () => ({ success: false }) },
|
||||
}));
|
||||
// v1.20.0 (F1) — the route statically imports the tool barrel; this suite
|
||||
// never reaches tool code (it asserts the 403 module-gate short-circuit), so a
|
||||
// thin stub keeps the import graph satisfied without pulling the real schemas.
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [],
|
||||
MAX_ROUNDS: 2,
|
||||
buildCoachDataInventory: vi.fn(),
|
||||
renderDataInventory: vi.fn(),
|
||||
buildToolModeAddendum: vi.fn(),
|
||||
runCoachToolLoop: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(),
|
||||
createConversation: vi.fn(),
|
||||
|
||||
@@ -58,8 +58,11 @@ vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
runRawCompletionWithFallback,
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
// v1.20.0 (F1) — pin a no-tools provider so this suite exercises the legacy
|
||||
// snapshot-stuffing fallback path (the behaviour it was written to pin). The
|
||||
// tool-retrieval path is covered by route-tool-mode.test.ts.
|
||||
resolveProviderChain: vi.fn(async () => [
|
||||
{ providerType: "admin-openai", instance: {} },
|
||||
{ providerType: "admin-openai", instance: { supportsTools: false } },
|
||||
]),
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.20.0 (F1) — Coach tool-retrieval routing contract.
|
||||
*
|
||||
* - When every provider in the chain supports tools, the route runs the tool
|
||||
* loop with a base context (DATA INVENTORY, NOT the snapshot figures), and
|
||||
* persists the tool trace onto provenance.
|
||||
* - When any provider lacks tool support, the route falls back to the legacy
|
||||
* snapshot-stuffing single completion verbatim.
|
||||
* - The inbound gates (rate limit, refusal, consent, budget reservation) all
|
||||
* fire BEFORE the loop in both modes.
|
||||
*/
|
||||
|
||||
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })),
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(async () => ({ coachPrefsJson: null })) },
|
||||
coachConversation: { findFirst: vi.fn(async () => ({ id: "c1" })) },
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkRateLimit } = vi.hoisted(() => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit }));
|
||||
vi.mock("@/lib/i18n/server-locale", () => ({
|
||||
resolveServerLocale: vi.fn(async () => "en"),
|
||||
}));
|
||||
|
||||
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
const { resolveProviderChain } = vi.hoisted(() => ({
|
||||
resolveProviderChain: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain,
|
||||
resolveProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
const { assertConsentForChain } = vi.hoisted(() => ({
|
||||
assertConsentForChain: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain }));
|
||||
vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" }));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { coach: { maxTokens: 1500, temperature: 0.4 } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/types", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/ai/coach/types")>(
|
||||
"@/lib/ai/coach/types",
|
||||
);
|
||||
return actual;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/ai/coach/persistence", () => ({
|
||||
appendMessage: vi.fn(async () => ({ id: "m1" })),
|
||||
createConversation: vi.fn(async () => ({ id: "c1" })),
|
||||
fetchConversationWithMessages: vi.fn(),
|
||||
listConversations: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/coach-memory-shared", () => ({
|
||||
enqueueCoachMemoryRefresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/facts", () => ({
|
||||
storeDeterministicFacts: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { reserveBudget, reconcileSpend } = vi.hoisted(() => ({
|
||||
reserveBudget: vi.fn<
|
||||
() => Promise<{
|
||||
allowed: boolean;
|
||||
reserved: number;
|
||||
totalAfter?: number;
|
||||
}>
|
||||
>(async () => ({ allowed: true, reserved: 3000 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-06-21"),
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
}));
|
||||
|
||||
const { detectRefusal } = vi.hoisted(() => ({
|
||||
detectRefusal: vi.fn(() => ({ refuse: false })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal }));
|
||||
vi.mock("@/lib/ai/coach/outbound-guard", () => ({
|
||||
screenCoachReply: vi.fn(() => ({ block: false })),
|
||||
coachOutboundFallback: vi.fn(() => "fallback"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/system-prompt", () => ({
|
||||
getCoachSystemPrompt: vi.fn(() => "SYSTEM"),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/snapshot", () => ({
|
||||
buildCoachSnapshot: vi.fn(async () => ({
|
||||
snapshotJson: SNAPSHOT_JSON,
|
||||
sections: { bloodPressure: { aggregate: { mean: 128 } } },
|
||||
provenance: { windows: ["last30days"], metrics: ["bp"] },
|
||||
referenceGrounding: "REFERENCE RANGES",
|
||||
})),
|
||||
}));
|
||||
|
||||
// Tool-module surface — stub the inventory + loop so we assert routing only.
|
||||
const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } =
|
||||
vi.hoisted(() => ({
|
||||
buildCoachDataInventory: vi.fn(async () => ({
|
||||
entries: [],
|
||||
restMode: false,
|
||||
cycleEnabled: false,
|
||||
window: "last30days",
|
||||
})),
|
||||
renderDataInventory: vi.fn(
|
||||
() => "DATA INVENTORY\n- blood pressure: present",
|
||||
),
|
||||
runCoachToolLoop: vi.fn(async () => ({
|
||||
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
|
||||
workingProviderType: "anthropic",
|
||||
totalTokens: 80,
|
||||
rounds: 2,
|
||||
toolTrace: [{ name: "get_metric_series", present: true }],
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/tools", () => ({
|
||||
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
|
||||
MAX_ROUNDS: 2,
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
|
||||
runCoachToolLoop,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/keyvalues", () => ({
|
||||
parseKeyValuesSentinel: vi.fn(() => ({
|
||||
prose: "Your BP is steady.",
|
||||
keyValues: [],
|
||||
malformed: false,
|
||||
malformedEntries: [],
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({
|
||||
parseSuggestReminder: vi.fn(() => ({ prose: "Your BP is steady." })),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/suggest-gate", () => ({ gateSuggestion: vi.fn() }));
|
||||
vi.mock("@/lib/validations/coach-prefs", () => ({
|
||||
parseCoachPrefs: vi.fn(() => ({ defaultWindow: undefined })),
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS: {},
|
||||
}));
|
||||
|
||||
const { appendMessage } = await import("@/lib/ai/coach/persistence");
|
||||
|
||||
vi.mock("@/lib/sse/create-stream", () => ({
|
||||
createSseStream: (
|
||||
producer: (c: {
|
||||
signal: { aborted: boolean };
|
||||
enqueue: () => void;
|
||||
}) => void | Promise<void>,
|
||||
) => {
|
||||
void Promise.resolve(
|
||||
producer({ signal: { aborted: true }, enqueue: () => {} }),
|
||||
);
|
||||
return new ReadableStream();
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
|
||||
const post = POST as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function chatReq(body: Record<string, unknown>): Request {
|
||||
return new Request("http://localhost/api/insights/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("coach chat — tool-mode routing (F1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
reserveBudget.mockResolvedValue({ allowed: true, reserved: 3000 });
|
||||
detectRefusal.mockReturnValue({ refuse: false });
|
||||
checkRateLimit.mockResolvedValue({ allowed: true });
|
||||
assertConsentForChain.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("runs the tool loop (not the snapshot path) when the chain supports tools", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} }, // no supportsTools=false ⇒ tools on
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
|
||||
expect(runCoachToolLoop).toHaveBeenCalledTimes(1);
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
// The base context carries the inventory, NOT the snapshot figures.
|
||||
const loopArgs = (runCoachToolLoop.mock.calls[0] as unknown[])[0] as {
|
||||
messages: Array<{ content: string }>;
|
||||
};
|
||||
const userContent = loopArgs.messages[0].content;
|
||||
expect(userContent).toContain("DATA INVENTORY");
|
||||
expect(userContent).not.toContain(SNAPSHOT_JSON);
|
||||
});
|
||||
|
||||
it("reserves the multi-round budget in tool mode", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
// maxTokens (1500) × MAX_ROUNDS (2).
|
||||
expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21");
|
||||
});
|
||||
|
||||
it("persists the tool trace onto provenance", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
|
||||
const assistantCall = calls.find(
|
||||
(c) => (c[0] as { role: string }).role === "assistant",
|
||||
);
|
||||
expect(
|
||||
(assistantCall?.[0] as { metricSource: { toolCalls?: unknown } })
|
||||
.metricSource.toolCalls,
|
||||
).toEqual([{ name: "get_metric_series", present: true }]);
|
||||
});
|
||||
|
||||
it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
{ providerType: "local", instance: { supportsTools: false } },
|
||||
]);
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" },
|
||||
workingProvider: { providerType: "anthropic" },
|
||||
});
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
|
||||
expect(runCoachToolLoop).not.toHaveBeenCalled();
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
|
||||
// Single-round budget reservation in the fallback path.
|
||||
expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21");
|
||||
// The legacy path ships the snapshot figures in the user turn.
|
||||
const params = (
|
||||
(runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as {
|
||||
params: { messages: Array<{ content: string }> };
|
||||
}
|
||||
).params;
|
||||
expect(params.messages[0].content).toContain(SNAPSHOT_JSON);
|
||||
});
|
||||
|
||||
it("fires the inbound gates BEFORE the loop (consent throws ⇒ no loop)", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
assertConsentForChain.mockRejectedValue(new Error("consent.ai.required"));
|
||||
await expect(post(chatReq({ message: "How is my BP?" }))).rejects.toThrow(
|
||||
"consent.ai.required",
|
||||
);
|
||||
expect(runCoachToolLoop).not.toHaveBeenCalled();
|
||||
expect(reserveBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses over-budget before entering the loop", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
reserveBudget.mockResolvedValue({
|
||||
allowed: false,
|
||||
reserved: 3000,
|
||||
totalAfter: 99999,
|
||||
});
|
||||
await post(chatReq({ message: "How is my BP?" }));
|
||||
expect(runCoachToolLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,16 @@ import {
|
||||
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
|
||||
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import {
|
||||
COACH_TOOL_DEFS,
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
buildToolModeAddendum,
|
||||
runCoachToolLoop,
|
||||
MAX_ROUNDS,
|
||||
type CoachToolTrace,
|
||||
} from "@/lib/ai/coach/tools";
|
||||
import type { AiMessage } from "@/lib/ai/types";
|
||||
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
||||
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
|
||||
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
|
||||
@@ -419,6 +429,36 @@ The user's message answers this clarifying question from their self-context ques
|
||||
React briefly and personally to the answer; do not repeat the question and do not ask it again.
|
||||
`
|
||||
: "";
|
||||
|
||||
// ── Provider chain ──────────────────────────────────────────
|
||||
// v1.20.0 (F1) — resolved BEFORE the prompt is built so we know whether to
|
||||
// run the tool-based retrieval path or the legacy snapshot-stuffing path.
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length === 0) {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") {
|
||||
annotate({
|
||||
action: { name: "insights.coach.noProvider" },
|
||||
});
|
||||
return streamProviderError({ code: "coach.provider.none" });
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
|
||||
// v1.12.1 — consent gate before any server-managed external egress. When
|
||||
// the chain could egress via the operator's global key, require an active
|
||||
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
|
||||
// chains are the user's own egress and stay ungated. Throws
|
||||
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
|
||||
await assertConsentForChain({ userId, chain, surface: "coach" });
|
||||
|
||||
// v1.20.0 (F1) — tool mode is on only when EVERY provider in the chain
|
||||
// supports tool-calling, so whichever hop the fallback runner lands on can
|
||||
// still serve tools. A chain that includes a no-tools provider (local /
|
||||
// Ollama) falls back to the legacy snapshot-stuffing path verbatim, exactly
|
||||
// as before — the snapshot builder stays alive as the no-tools floor.
|
||||
const toolMode = chain.every((c) => c.instance.supportsTools !== false);
|
||||
|
||||
// v1.18.6 (W7) — citation-aware reference-range grounding for the metrics
|
||||
// present in this snapshot. Deterministic + brand-free; framed as general
|
||||
// guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully
|
||||
@@ -447,26 +487,6 @@ ${transcript}
|
||||
|
||||
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
|
||||
// ── Provider chain ──────────────────────────────────────────
|
||||
const chain = await resolveProviderChain(userId);
|
||||
if (chain.length === 0) {
|
||||
const legacy = await resolveProvider(userId);
|
||||
if (legacy.type === "none") {
|
||||
annotate({
|
||||
action: { name: "insights.coach.noProvider" },
|
||||
});
|
||||
return streamProviderError({ code: "coach.provider.none" });
|
||||
}
|
||||
chain.push({ providerType: "admin-openai", instance: legacy });
|
||||
}
|
||||
|
||||
// v1.12.1 — consent gate before any server-managed external egress. When
|
||||
// the chain could egress via the operator's global key, require an active
|
||||
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
|
||||
// chains are the user's own egress and stay ungated. Throws
|
||||
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
|
||||
await assertConsentForChain({ userId, chain, surface: "coach" });
|
||||
|
||||
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
|
||||
// the provider call. The reservation increments the day's total by the
|
||||
// per-call ceiling (`maxTokens`) in one upsert and returns the new total;
|
||||
@@ -474,10 +494,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
// Over-cap → 429 refusal frame, reservation already refunded. The actual
|
||||
// token count is reconciled against this reservation after the call,
|
||||
// including on empty / sentinel replies (their tokens were still burned).
|
||||
//
|
||||
// v1.20.0 (F1) — the tool loop makes up to MAX_ROUNDS provider round-trips,
|
||||
// so reserve the per-call ceiling × the round count up front and reconcile
|
||||
// the SUMMED actual tokens afterwards. The atomic reserve/reconcile
|
||||
// primitives are unchanged; only the reserved amount scales.
|
||||
const reqDateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.coach.maxTokens,
|
||||
toolMode
|
||||
? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS
|
||||
: AI_BUDGETS.coach.maxTokens,
|
||||
reqDateKey,
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
@@ -490,23 +517,62 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
|
||||
let result: CompletionResult;
|
||||
let workingProviderType: string;
|
||||
let toolTrace: CoachToolTrace[] = [];
|
||||
let totalTokensSpent: number;
|
||||
try {
|
||||
const fallback = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers: chain,
|
||||
// v1.20.0 — the Coach still builds one assembled user turn (the
|
||||
// transcript-flattening preserves the once-per-conversation snapshot
|
||||
// trick + grounding exactly), so it ships as a single user message. The
|
||||
// stable persona/grounding rides `system` and is now cache-eligible.
|
||||
params: singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
if (toolMode) {
|
||||
// v1.20.0 (F1) — base context: the full system prompt + a tool-mode
|
||||
// grounding addendum, with the tiny DATA INVENTORY manifest and the
|
||||
// transcript on the user turn. The figures are NOT in the prompt — the
|
||||
// model pulls only what it needs via the retrieval tools. The inventory
|
||||
// build reuses the snapshot we already computed (60s LRU), so the tools
|
||||
// that fire this turn share its reads.
|
||||
const inventory = await buildCoachDataInventory(userId, effectiveScope);
|
||||
const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`;
|
||||
const messages: AiMessage[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: `${renderDataInventory(inventory)}${guidedBlock}
|
||||
|
||||
CONVERSATION
|
||||
${transcript}
|
||||
|
||||
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`,
|
||||
},
|
||||
];
|
||||
const loop = await runCoachToolLoop({
|
||||
userId,
|
||||
providers: chain,
|
||||
system: toolSystem,
|
||||
messages,
|
||||
tools: COACH_TOOL_DEFS,
|
||||
temperature: AI_BUDGETS.coach.temperature,
|
||||
maxTokens: AI_BUDGETS.coach.maxTokens,
|
||||
}),
|
||||
});
|
||||
result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
fallbackWindow: effectiveScope?.window,
|
||||
});
|
||||
result = loop.result;
|
||||
workingProviderType = loop.workingProviderType;
|
||||
toolTrace = loop.toolTrace;
|
||||
totalTokensSpent = loop.totalTokens;
|
||||
} else {
|
||||
const fallback = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers: chain,
|
||||
// v1.20.0 — the no-tools path still builds one assembled user turn (the
|
||||
// transcript-flattening preserves the once-per-conversation snapshot
|
||||
// trick + grounding exactly), so it ships as a single user message. The
|
||||
// stable persona/grounding rides `system` and is now cache-eligible.
|
||||
params: singleUserTurn({
|
||||
system: systemPrompt,
|
||||
user: userPrompt,
|
||||
temperature: AI_BUDGETS.coach.temperature,
|
||||
maxTokens: AI_BUDGETS.coach.maxTokens,
|
||||
}),
|
||||
});
|
||||
result = fallback.result;
|
||||
workingProviderType = fallback.workingProvider.providerType;
|
||||
totalTokensSpent = result.tokensUsed ?? 0;
|
||||
}
|
||||
} catch (err) {
|
||||
// The provider chain failed outright — no tokens were billed, so refund
|
||||
// the full reservation before surfacing the error frame.
|
||||
@@ -555,10 +621,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
// against the actual count NOW, before any empty/sentinel short-circuit, so
|
||||
// an empty or sentinel-only reply still records its burned cost (the old
|
||||
// post-hoc `recordSpend` ran only on the happy path, undercounting these).
|
||||
// v1.20.0 (F1) — reconcile against the SUMMED tokens across every tool round
|
||||
// (the loop accumulates them); the no-tools path sums to the single call.
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
result.tokensUsed ?? 0,
|
||||
totalTokensSpent,
|
||||
reqDateKey,
|
||||
).catch(() => {
|
||||
// Ledger reconcile is best-effort; a failure leaves the conservative
|
||||
@@ -681,6 +749,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
? { keyValues: sentinel.keyValues }
|
||||
: {}),
|
||||
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
|
||||
// v1.20.0 (F1) — persist the retrieval-tool trace (which tools ran +
|
||||
// whether each found data) so a reload can show "what I looked at" and the
|
||||
// audit can replay grounding. Metadata only.
|
||||
...(toolTrace.length > 0
|
||||
? {
|
||||
toolCalls: toolTrace.map((t) => ({
|
||||
name: t.name,
|
||||
present: t.present,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
@@ -720,7 +799,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
// v1.18.9 — persist the per-turn token count + model so the quiet
|
||||
// token footer survives a conversation reload. The live turn paints
|
||||
// from the `done.usage` SSE frame below; reloads read these columns.
|
||||
tokensUsed: result.tokensUsed ?? null,
|
||||
// v1.20.0 (F1) — the summed cost across every tool round, so the footer
|
||||
// reflects the true turn cost on the tool path too.
|
||||
tokensUsed: totalTokensSpent || null,
|
||||
model: result.model ?? null,
|
||||
});
|
||||
|
||||
@@ -733,14 +814,21 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
action: { name: "insights.coach.replied" },
|
||||
meta: {
|
||||
provider: workingProviderType,
|
||||
tokens: result.tokensUsed ?? null,
|
||||
// v1.20.0 (F1) — summed tokens across every tool round (the loop) or the
|
||||
// single call (no-tools path), so the dashboards see the true turn cost.
|
||||
tokens: totalTokensSpent,
|
||||
promptVersion: PROMPT_VERSION,
|
||||
conversationId: workingConversationId,
|
||||
historyTurns: window.length,
|
||||
// v1.20.0 (F1) — whether this turn ran the tool-retrieval path, and how
|
||||
// many tools it fetched, so the dashboards can correlate the token delta
|
||||
// with the new path vs the legacy snapshot path.
|
||||
toolMode,
|
||||
toolsCalled: toolTrace.length,
|
||||
// v1.19.1 (C4) — whether the full SNAPSHOT block rode this turn (the
|
||||
// expensive prefix) vs the cheap pointer. Lets the dashboards confirm
|
||||
// the once-per-conversation grounding cut the tokens-per-message.
|
||||
snapshotSent: includeFullSnapshot,
|
||||
// expensive prefix) vs the cheap pointer. On the tool path the snapshot
|
||||
// never rides the prompt, so this is the legacy-path signal only.
|
||||
snapshotSent: !toolMode && includeFullSnapshot,
|
||||
promptChars: userPrompt.length,
|
||||
// v1.7.0 — count of provenance metrics the snapshot surfaced
|
||||
// this turn (a proxy for cluster breadth) so the dashboards can
|
||||
@@ -793,7 +881,8 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
conversationId: workingConversationId,
|
||||
messageId: assistantMessage.id,
|
||||
usage: {
|
||||
totalTokens: result.tokensUsed ?? null,
|
||||
// v1.20.0 (F1) — summed across every tool round.
|
||||
totalTokens: totalTokensSpent || null,
|
||||
model: result.model ?? null,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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