perf(coach): send the health snapshot once per conversation

The full SNAPSHOT block (every cluster plus labs, illness, cycle and the
reference-grounding lines) was interpolated into the prompt on every turn,
so a one-word follow-up re-paid the entire grounding prefix. Ship it only on
the first turn, or once the history window starts eliding turns; on a cheap
follow-up send a short pointer back to the snapshot the model already received
earlier in the same conversation. The prior turns ride the transcript, so
grounding and cross-metric correlation are preserved while a follow-up turn
sheds about 6,000 tokens. Record snapshotSent and promptChars on the reply
annotation so the cut is observable.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 13:56:49 +02:00
1 parent bbbafca7f4
commit bc1f30fec4
2 files changed
+248 -5

No files matched your search

@@ -0,0 +1,209 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* v1.19.1 (C4) — token-efficiency contract. The full SNAPSHOT block (the
* expensive grounding prefix) must ride the prompt only ONCE per conversation:
* on the first turn (no prior turns on disk). A follow-up turn that still sits
* inside the verbatim history window must send a short pointer in place of the
* figures, so typing one word no longer re-ships ~15k tokens of snapshot. This
* suite captures the `userPrompt` handed to the provider runner on each turn
* and pins both the first-turn-full and follow-up-cheap shapes.
*/
const SNAPSHOT_JSON = '{"bp":{"aggregate":{"mean":128}}}';
const GROUNDING = "REFERENCE RANGES\nBP optimal < 120/80.";
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" })) },
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
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,
}));
vi.mock("@/lib/ai/provider", () => ({
resolveProviderChain: vi.fn(async () => [
{ providerType: "admin-openai", instance: {} },
]),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/consent-guard", () => ({
assertConsentForChain: vi.fn(async () => undefined),
}));
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;
});
const { fetchConversationWithMessages } = vi.hoisted(() => ({
fetchConversationWithMessages: vi.fn(),
}));
vi.mock("@/lib/ai/coach/persistence", () => ({
appendMessage: vi.fn(async () => ({ id: "m1" })),
createConversation: vi.fn(async () => ({ id: "c1" })),
fetchConversationWithMessages,
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),
}));
vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-21"),
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
}));
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,
provenance: { windows: ["last30days"], metrics: ["bp"] },
referenceGrounding: GROUNDING,
})),
}));
vi.mock("@/lib/ai/coach/keyvalues", () => ({
parseKeyValuesSentinel: vi.fn(() => ({
prose: "Your BP looks stable.",
keyValues: [],
malformed: false,
malformedEntries: [],
})),
}));
vi.mock("@/lib/ai/coach/suggest-reminder", () => ({
parseSuggestReminder: vi.fn(() => ({ prose: "Your BP looks stable." })),
}));
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: {},
}));
vi.mock("@/lib/sse/create-stream", () => ({
// Drain the producer synchronously so the handler completes; we only care
// about the provider call args, not the wire frames.
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),
});
}
function lastUserPrompt(): string {
const calls = runRawCompletionWithFallback.mock.calls as unknown as Array<
[{ params: { userPrompt: string } }]
>;
const last = calls[calls.length - 1];
return last[0].params.userPrompt;
}
describe("coach chat — snapshot sent once per conversation (C4)", () => {
beforeEach(() => {
vi.clearAllMocks();
runRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "admin-openai" },
});
});
it("ships the full SNAPSHOT + grounding on the first turn", async () => {
await post(chatReq({ message: "How is my BP?" }));
const prompt = lastUserPrompt();
expect(prompt).toContain(SNAPSHOT_JSON);
expect(prompt).toContain(GROUNDING);
});
it("does NOT re-ship the snapshot figures on a follow-up turn", async () => {
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
summary: null,
messages: [
{ role: "user", content: "How is my BP?" },
{ role: "assistant", content: "Your BP looks stable." },
],
});
await post(chatReq({ conversationId: "c1", message: "High?" }));
const prompt = lastUserPrompt();
// The expensive figures + grounding must be gone …
expect(prompt).not.toContain(SNAPSHOT_JSON);
expect(prompt).not.toContain(GROUNDING);
// … replaced by the cheap pointer back to the earlier snapshot, and the
// prior turns still ride the transcript so grounding is preserved.
expect(prompt).toContain("provided earlier in this conversation");
expect(prompt).toContain("Your BP looks stable.");
});
});
+39 -5
View File
@@ -387,6 +387,24 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
locale: locale === "en" ? "en" : "de",
});
}
// v1.19.1 (C4) — token efficiency. The full SNAPSHOT (now incl. labs,
// illness, cycle, every cluster + the reference-grounding block) is the
// single biggest cost in a Coach turn — typing one word used to re-ship the
// whole ~15k-token block. The grounding only needs to enter the prompt ONCE
// per conversation: the model's first reply is composed against it and that
// reply rides the conversation transcript on every following turn, so the
// figures stay in-context without re-paying for them. We therefore include
// the full block only when:
// - this is the first turn (no prior turns on disk), OR
// - the history window has begun eliding turns (`allTurns.length > TURN_CAP`)
// — once the oldest turns are folded into the rolling summary the original
// snapshot may have scrolled out of the verbatim window, so we re-ground.
// On the cheap path (a follow-up inside the verbatim window) we send a short
// pointer instead of the figures, preserving grounding + cross-metric
// correlation quality at a fraction of the wire cost.
const isFirstTurn = priorTurns.length === 0;
const historyEliding = allTurns.length > TURN_CAP;
const includeFullSnapshot = isFirstTurn || historyEliding;
const transcript = window
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
.join("\n\n");
@@ -407,12 +425,23 @@ React briefly and personally to the answer; do not repeat the question and do no
// inspectable and the model reads the published population bands + the
// user's placement. Omitted entirely when no present metric is covered by
// the reference backbone (the builder returns null).
const groundingBlock = snapshot.referenceGrounding
? `\n${snapshot.referenceGrounding}\n`
: "";
const userPrompt = `SNAPSHOT
const groundingBlock =
includeFullSnapshot && snapshot.referenceGrounding
? `\n${snapshot.referenceGrounding}\n`
: "";
// v1.19.1 (C4) — the SNAPSHOT block is the expensive prefix. On the first
// turn (or after the history window starts eliding) we ship the full figures;
// on a cheap follow-up we ship a one-line pointer back to the snapshot the
// model already received earlier in this same conversation, so it keeps
// grounding its numbers in that data without us re-paying for the block.
const snapshotBlock = includeFullSnapshot
? `SNAPSHOT
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
${groundingBlock}${guidedBlock}
${groundingBlock}`
: `SNAPSHOT
(The full health snapshot was provided earlier in this conversation — keep grounding your answer in those figures. Do not invent numbers you were not given.)
`;
const userPrompt = `${snapshotBlock}${guidedBlock}
CONVERSATION
${transcript}
@@ -704,6 +733,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
promptVersion: PROMPT_VERSION,
conversationId: workingConversationId,
historyTurns: window.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,
promptChars: userPrompt.length,
// v1.7.0 — count of provenance metrics the snapshot surfaced
// this turn (a proxy for cluster breadth) so the dashboards can
// correlate reply shape with cluster activation.