From 0c68d07d9e570848b76f76dc06d40a340fbc3e88 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:35:21 +0800 Subject: [PATCH] perf: trim the answer payload to what the client renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 phase 4. The final SSE event (and the non-stream answer JSON) shipped every source's full chunk content plus server-only context, landing as one large frame after the prose had already streamed — pure transfer/serialize tail on answer completion. toClientAnswerPayload (new answer-client-payload.ts) now trims sources at the ROUTE BOUNDARY only: - Drops adjacent_context, memory_cards, table_facts, index_unit, and document_summary — audited to have zero client consumers (generation packing and evidence building consume them server-side, before the trim). - Truncates content to 700 chars at a word boundary; the client reads content only as the snippet fallback (retrieval_synopsis ?? content, answer-render-policy.ts). retrieval_synopsis, source_metadata, document_labels, scoring, and identity fields pass through untouched. - rag.ts, both cache layers, eval behavior, and logAnswerDiagnostics see the full answer unchanged; sourceGovernanceWarnings still compute from the full sources before the trim (ordering documented at both call sites). - Trim is non-mutating, so cached answers keep full sources (unit-tested). A representative 8-source answer payload shrinks by more than half (unit-tested lower bound; real chunks are typically larger than the fixture). Verified: tsc, eslint (0 warnings), prettier, targeted vitest including new answer-client-payload suite (125 tests green), and full verify:ui — 124 passed / 0 failed with phases 0-4 stacked. Co-Authored-By: Claude Fable 5 --- src/app/api/answer/route.ts | 5 +- src/app/api/answer/stream/route.ts | 5 +- src/lib/answer-client-payload.ts | 42 ++++++++++++++ tests/answer-client-payload.test.ts | 85 +++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/lib/answer-client-payload.ts create mode 100644 tests/answer-client-payload.test.ts diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index b63f20705..f02022dbc 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -20,6 +20,7 @@ import { sourceGovernanceWarnings, } from "@/lib/source-governance"; import { parseJsonBody } from "@/lib/validation/body"; +import { toClientAnswerPayload } from "@/lib/answer-client-payload"; import { answerServerTimingEntries, buildServerTimingHeader } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; @@ -166,7 +167,9 @@ export async function POST(request: Request) { ); return NextResponse.json( { - ...answer, + // Boundary trim only — governance warnings and diagnostics above + // consumed the full answer (see answer-client-payload.ts). + ...toClientAnswerPayload(answer), degradedMode: answerDegradedModeSignal(answer), scope: { ...scope, queryMode: answerBody.queryMode }, sourceGovernanceWarnings: warnings, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index d3dbc6732..3183bf3ad 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -8,6 +8,7 @@ import { type ApiRateLimitResult, } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; +import { toClientAnswerPayload } from "@/lib/answer-client-payload"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; import { annotateSearchResults, buildEvidenceRelevance } from "@/lib/evidence-relevance"; @@ -231,7 +232,9 @@ function streamAnswer( } send("final", { - ...answer, + // Boundary trim only — governance warnings and diagnostics above + // consumed the full answer (see answer-client-payload.ts). + ...toClientAnswerPayload(answer), degradedMode: answerDegradedModeSignal(answer), scope: scope ? { ...scope, queryMode: body.queryMode } : undefined, sourceGovernanceWarnings: warnings, diff --git a/src/lib/answer-client-payload.ts b/src/lib/answer-client-payload.ts new file mode 100644 index 000000000..0a42ee9e7 --- /dev/null +++ b/src/lib/answer-client-payload.ts @@ -0,0 +1,42 @@ +import type { RagAnswer, SearchResult } from "@/lib/types"; + +// Route-boundary trim of the answer payload. The retrieval pipeline carries +// full chunk text plus server-only context on every source (adjacent_context +// for generation packing, memory cards, table facts, index-unit matches, +// document summaries), but the client renders only a snippet +// (retrieval_synopsis ?? content) plus identity, scoring, governance, and +// label fields. Trimming at the route boundary — never inside rag.ts — keeps +// caches, generation inputs, and eval behavior byte-identical while cutting +// the final SSE/JSON event the user waits on after the prose has streamed. +// +// Ordering contract: sourceGovernanceWarnings and logAnswerDiagnostics consume +// the FULL answer and must run before this trim (both routes do). + +// Longest snippet the source cards can usefully show; the render policy falls +// back to `content` only when `retrieval_synopsis` is absent. +const clientSourceContentMaxChars = 700; + +function truncateAtWordBoundary(text: string, maxChars: number) { + if (text.length <= maxChars) return text; + const slice = text.slice(0, maxChars); + const lastSpace = slice.lastIndexOf(" "); + return `${slice.slice(0, lastSpace > maxChars * 0.6 ? lastSpace : maxChars).trimEnd()}…`; +} + +function trimSourceForClient(source: SearchResult): SearchResult { + const trimmed: SearchResult = { + ...source, + content: truncateAtWordBoundary(source.content ?? "", clientSourceContentMaxChars), + }; + delete trimmed.adjacent_context; + delete trimmed.memory_cards; + delete trimmed.table_facts; + delete trimmed.index_unit; + delete trimmed.document_summary; + return trimmed; +} + +export function toClientAnswerPayload>(answer: T): T { + if (!answer.sources?.length) return answer; + return { ...answer, sources: answer.sources.map(trimSourceForClient) }; +} diff --git a/tests/answer-client-payload.test.ts b/tests/answer-client-payload.test.ts new file mode 100644 index 000000000..182298af4 --- /dev/null +++ b/tests/answer-client-payload.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { toClientAnswerPayload } from "@/lib/answer-client-payload"; +import type { RagAnswer, SearchResult } from "@/lib/types"; + +function fullSource(overrides: Partial = {}): SearchResult { + return { + id: "chunk-1", + document_id: "doc-1", + title: "Clozapine monitoring guideline", + file_name: "clozapine.pdf", + page_number: 4, + chunk_index: 7, + section_heading: "Monitoring", + content: "Full blood count weekly for 18 weeks. ".repeat(60), + retrieval_synopsis: "FBC weekly for 18 weeks, then monthly.", + image_ids: [], + similarity: 0.82, + source_metadata: { document_status: "current" } as SearchResult["source_metadata"], + adjacent_context: "Preceding paragraph context. ".repeat(20), + document_summary: "A long document summary. ".repeat(30), + memory_cards: [{ id: "m1" } as never], + table_facts: [{ id: "t1" } as never], + index_unit: { unit_type: "table" } as never, + ...overrides, + } as SearchResult; +} + +function answerWith(sources: SearchResult[]): Pick { + return { sources }; +} + +describe("toClientAnswerPayload", () => { + it("drops server-only per-source fields the client never renders", () => { + const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; + expect(trimmed.adjacent_context).toBeUndefined(); + expect(trimmed.memory_cards).toBeUndefined(); + expect(trimmed.table_facts).toBeUndefined(); + expect(trimmed.index_unit).toBeUndefined(); + expect(trimmed.document_summary).toBeUndefined(); + }); + + it("keeps identity, snippet, scoring, and governance fields intact", () => { + const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; + expect(trimmed.id).toBe("chunk-1"); + expect(trimmed.title).toBe("Clozapine monitoring guideline"); + expect(trimmed.retrieval_synopsis).toBe("FBC weekly for 18 weeks, then monthly."); + expect(trimmed.similarity).toBe(0.82); + expect(trimmed.source_metadata).toEqual({ document_status: "current" }); + expect(trimmed.page_number).toBe(4); + }); + + it("truncates long chunk content at a word boundary", () => { + const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; + expect(trimmed.content.length).toBeLessThanOrEqual(701); + expect(trimmed.content.endsWith("…")).toBe(true); + // Word-boundary cut: no partial trailing token before the ellipsis. + expect(trimmed.content.at(-2)).not.toBe(" "); + }); + + it("leaves short content untouched", () => { + const short = fullSource({ content: "Short snippet." }); + expect(toClientAnswerPayload(answerWith([short])).sources![0].content).toBe("Short snippet."); + }); + + it("does not mutate the original answer (caches keep the full sources)", () => { + const source = fullSource(); + const answer = answerWith([source]); + toClientAnswerPayload(answer); + expect(answer.sources![0].adjacent_context).toBeTruthy(); + expect(answer.sources![0].content.length).toBeGreaterThan(700); + }); + + it("passes through answers without sources", () => { + const empty = answerWith([]); + expect(toClientAnswerPayload(empty)).toBe(empty); + }); + + it("materially shrinks a representative payload", () => { + const answer = answerWith(Array.from({ length: 8 }, (_, index) => fullSource({ id: `chunk-${index}` }))); + const fullBytes = JSON.stringify(answer).length; + const trimmedBytes = JSON.stringify(toClientAnswerPayload(answer)).length; + expect(trimmedBytes).toBeLessThan(fullBytes * 0.5); + }); +});