mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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.");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user