diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts new file mode 100644 index 000000000..1ce27f76c --- /dev/null +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -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: 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( + "@/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 Promise.resolve( + producer({ signal: { aborted: true }, enqueue: () => {} }), + ); + return new ReadableStream(); + }, +})); + +import { POST } from "../route"; + +const post = POST as unknown as (req: Request) => Promise; + +function chatReq(body: Record): 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."); + }); +}); diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index f73812a66..a0589fec3 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -387,6 +387,24 @@ async function handleChatRequest(request: NextRequest): Promise { 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.