mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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,
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user