diff --git a/.env.example b/.env.example index 1f32a51988..ffe8b0b352 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,9 @@ SENTRY_ENVIRONMENT=production # on the answer stream. Server emission flag; client rendering ships separately. Keep false # until the offline contract proof has landed and enablement is deliberately staged. RAG_INCREMENTAL_EVIDENCE_PREVIEW=false +# #100 Phase 1 client rendering gate. This NEXT_PUBLIC value is inlined at build time; +# keep it off until focused browser, accessibility, and clinical-governance proof is accepted. +#NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER=false # Ambiguity-only structured semantic reranking. Keep false until the retrieval canary is approved. RAG_SEMANTIC_RERANK_ENABLED=false # Append OR-relaxed recall behind weak-but-nonzero strict text matches (P8b extension). diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index cd423461cc..223d050489 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -34,7 +34,12 @@ import { useUploadDesktopLayout } from "@/components/clinical-dashboard/use-uplo import { extractSafetyFindings } from "@/lib/clinical-safety"; import { resolveScrollBehavior } from "@/lib/scroll-behavior"; import { ownsVerticalScroll, scrollSurface } from "@/components/clinical-dashboard/scroll-surface"; -import { isLocalNoAuthMode, resolveClientDemoMode, resolveUploadReadOnlyMode } from "@/lib/client-env"; +import { + incrementalEvidencePreviewRenderingEnabled, + isLocalNoAuthMode, + resolveClientDemoMode, + resolveUploadReadOnlyMode, +} from "@/lib/client-env"; import { isAdministratorUser } from "@/lib/authorization"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isDeployedClinicalKb } from "@/lib/deployed-app"; @@ -82,6 +87,8 @@ import { type AnswerProgressUpdate, type TimedAnswerProgressUpdate, } from "@/components/clinical-dashboard/answer-progress"; +import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview"; +import { requestAnswerStream } from "@/components/clinical-dashboard/answer-request"; import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-map-model"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { PhoneFooterLayerFrame } from "@/components/clinical-dashboard/phone-footer-layer-portal"; @@ -100,7 +107,6 @@ import { import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; import { answerReferencesDocument, - answerTimedOutError, applyRenamedDocumentToAnswer, compactScopeFilters, hasActiveIndexingWork, @@ -171,12 +177,10 @@ import { keywordQueryFromNaturalLanguage, makeSearchError, progressForRetry, - readAnswerStream, searchRetryCount, searchRetryDelaysMs, sleep, type AnswerErrorKind, - type AnswerPayload, type SearchError, } from "@/components/clinical-dashboard/search-utils"; import { @@ -225,11 +229,13 @@ import { } from "@/components/clinical-dashboard/use-persisted-answer-thread"; import { buildAnswerClipboardText } from "@/components/clinical-dashboard/answer-copy-payload"; import { buildAnswerRenderModel, isAnswerSourceBacked } from "@/lib/answer-render-policy"; +import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; + import { type SmartDocumentTag, type SmartDocumentTagFacet } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -438,6 +444,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setError(null); setAnswerProgress(null); + setAnswerEvidencePreview(null); setDifferentialEvidenceQuery(null); }, [resetAnswerThread]); const [scopeFilters, setScopeFilters] = useState(initialSearchNavigationContext.scopeFilters); @@ -450,6 +457,7 @@ export function ClinicalDashboard({ const [answerProgress, setAnswerProgress] = useState(null); const [answerProgressEvents, setAnswerProgressEvents] = useState([]); const [answerProgressStartedAt, setAnswerProgressStartedAt] = useState(null); + const [answerEvidencePreview, setAnswerEvidencePreview] = useState(null); const [answerLifecycle, dispatchAnswerLifecycle] = useReducer(answerLifecycleReducer, initialAnswerLifecycle); const [error, setError] = useState(null); // Companion state for `error`, used to pick the right recovery UI (retry vs. @@ -1671,57 +1679,30 @@ export function ClinicalDashboard({ }; } - async function requestAnswer( + function requestAnswer( queryText: string, filtersOverride: SearchScopeFilters = scopeFilters, queryModeOverride: ClinicalQueryMode = requestQueryMode, onProgress: (progress: AnswerProgressUpdate) => void, + onEvidencePreview: (preview: VerifiedEvidencePreviewUnit | null) => void, signal?: AbortSignal, onStreamActivity?: () => void, ) { - let response: Response; - try { - response = await fetch("/api/answer/stream", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(clientDemoMode ? {} : authorizationHeader), - }, - body: JSON.stringify({ - query: queryText, - documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, - filters: compactScopeFilters(filtersOverride), - queryMode: queryModeOverride, - }), - signal, - }); - } catch (error) { - if (answerTimedOutRef.current) throw answerTimedOutError(); - if (isAbortError(error)) throw error; - throw searchNetworkFailure("Answer search"); - } - - if (response.status === 401) { - markSessionExpired(); - throw makeSearchError("Search request was not authorized by the server.", 401, false); - } - if (!response.ok) { - throw await parseApiErrorResponse(response); - } - - let payload: AnswerPayload; - try { - payload = await readAnswerStream(response, onProgress, onStreamActivity); - } catch (error) { - if (answerTimedOutRef.current) throw answerTimedOutError(); - if (isAbortError(error)) throw error; - throw error; - } - return { - kind: "answer" as const, - query: queryText, - payload, - }; + return requestAnswerStream({ + queryText, + filters: filtersOverride, + queryMode: queryModeOverride, + selectedDocumentIds, + clientDemoMode, + authorizationHeader, + onProgress, + onEvidencePreview, + signal, + onStreamActivity, + timedOut: () => answerTimedOutRef.current, + onSessionExpired: markSessionExpired, + networkFailure: () => searchNetworkFailure("Answer search"), + }); } async function runWithRetries( @@ -1774,6 +1755,7 @@ export function ClinicalDashboard({ setAnswerProgress(null); setAnswerProgressEvents([]); setAnswerProgressStartedAt(null); + setAnswerEvidencePreview(null); dispatchAnswerLifecycle({ type: "cancel" }); } @@ -1934,6 +1916,10 @@ export function ClinicalDashboard({ return [...current, { ...progress, receivedAt: Date.now() }].slice(-16); }); }; + const onAnswerEvidencePreview = (preview: VerifiedEvidencePreviewUnit | null) => { + if (!requestIsCurrent()) return; + setAnswerEvidencePreview(incrementalEvidencePreviewRenderingEnabled() ? preview : null); + }; const onRetryProgress = (message: string) => { if (isAnswerRequest) onAnswerProgress({ stage: "retrying", message }); else onProgress(message); @@ -1952,6 +1938,7 @@ export function ClinicalDashboard({ setSearchFacets(null); setSearchScope(null); setSourceGovernanceWarnings([]); + setAnswerEvidencePreview(null); setAnswerViewMode("high_yield"); if (isAnswerRequest) { const startedAt = Date.now(); @@ -2030,15 +2017,18 @@ export function ClinicalDashboard({ abortController.signal, ) : await runWithRetries( - () => - requestAnswer( + () => { + onAnswerEvidencePreview(null); + return requestAnswer( entry.query, filtersOverride, targetQueryMode, onAnswerProgress, + onAnswerEvidencePreview, abortController.signal, answerWatchdog.touch, - ), + ); + }, onRetryProgress, abortController.signal, ); @@ -3660,6 +3650,10 @@ export function ClinicalDashboard({ ) : null)} + {activeModeResultKind === "answer" && loading && answerEvidencePreview ? ( + + ) : null} + {showUniversalAlsoMatches && (activeModeResultKind === "tools" || activeModeResultKind === "documents" || diff --git a/src/components/clinical-dashboard/answer-evidence-preview.tsx b/src/components/clinical-dashboard/answer-evidence-preview.tsx new file mode 100644 index 0000000000..a576db6794 --- /dev/null +++ b/src/components/clinical-dashboard/answer-evidence-preview.tsx @@ -0,0 +1,67 @@ +"use client"; + +import Link from "next/link"; +import { FileText, ShieldCheck } from "lucide-react"; + +import { cleanDisplayTitle, compactSourceSnippet } from "@/components/clinical-dashboard/display-text"; +import { sourceResultHref } from "@/components/clinical-dashboard/source-actions"; +import { cn, panelSubtle, textMuted } from "@/components/ui-primitives"; +import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; + +const visiblePreviewSourceLimit = 3; + +export function AnswerEvidencePreview({ preview }: { preview: VerifiedEvidencePreviewUnit }) { + const visibleSources = preview.sources.slice(0, visiblePreviewSourceLimit); + + return ( +
+
+
+ + +
+

+ Selected evidence — answer still being verified +

+

+ {preview.selectedContextCount} source passage{preview.selectedContextCount === 1 ? "" : "s"} selected. The + final answer and source list remain authoritative. +

+
+
+
+ +
+ {visibleSources.map((source, index) => { + const title = cleanDisplayTitle(source.title); + const snippet = compactSourceSnippet(source.content, { dropTitle: title }); + return ( + +
+
+ {snippet ?

{snippet}

: null} + + ); + })} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/answer-progress.ts b/src/components/clinical-dashboard/answer-progress.ts index 94858661a7..2bfd194dbd 100644 --- a/src/components/clinical-dashboard/answer-progress.ts +++ b/src/components/clinical-dashboard/answer-progress.ts @@ -1,4 +1,5 @@ import type { PublicAnswerProgressEvent, PublicAnswerProgressStage } from "@/lib/answer-progress-public"; +import { isDeliverableVerifiedUnit } from "@/lib/answer-stream-contract"; export type AnswerProgressUpdate = PublicAnswerProgressEvent; export type TimedAnswerProgressUpdate = AnswerProgressUpdate & { receivedAt: number }; @@ -41,7 +42,10 @@ function finiteCount(value: unknown) { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined; } -export function normalizeAnswerProgressEvent(data: unknown): AnswerProgressUpdate | null { +export function normalizeAnswerProgressEvent( + data: unknown, + lastVerifiedUnitSequence: number | null = null, +): AnswerProgressUpdate | null { if (typeof data === "string" && data.trim()) { const message = data.trim(); return { stage: inferLegacyStage(message), message }; @@ -55,10 +59,14 @@ export function normalizeAnswerProgressEvent(data: unknown): AnswerProgressUpdat typeof value.stage === "string" && answerProgressStages.has(value.stage as PublicAnswerProgressStage) ? (value.stage as PublicAnswerProgressStage) : inferLegacyStage(message); + const verifiedUnit = isDeliverableVerifiedUnit(value.verifiedUnit, lastVerifiedUnitSequence) + ? value.verifiedUnit + : undefined; return { stage, message, + ...(verifiedUnit === undefined ? {} : { verifiedUnit }), resultCount: finiteCount(value.resultCount), selectedContextCount: finiteCount(value.selectedContextCount), australianSourceCount: finiteCount(value.australianSourceCount), diff --git a/src/components/clinical-dashboard/answer-request.ts b/src/components/clinical-dashboard/answer-request.ts new file mode 100644 index 0000000000..7452d1483e --- /dev/null +++ b/src/components/clinical-dashboard/answer-request.ts @@ -0,0 +1,94 @@ +import type { AnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress"; +import { + answerTimedOutError, + compactScopeFilters, + isAbortError, +} from "@/components/clinical-dashboard/clinical-dashboard-helpers"; +import { makeSearchError, readAnswerStream } from "@/components/clinical-dashboard/search-utils"; +import { parseApiErrorResponse } from "@/lib/api-client-error"; +import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; +import type { SearchScopeFilters } from "@/lib/search-scope"; +import type { ClinicalQueryMode } from "@/lib/types"; + +type AnswerRequestOptions = { + queryText: string; + filters: SearchScopeFilters; + queryMode: ClinicalQueryMode; + selectedDocumentIds: string[]; + clientDemoMode: boolean; + authorizationHeader: Record; + onProgress: (progress: AnswerProgressUpdate) => void; + onEvidencePreview: (preview: VerifiedEvidencePreviewUnit | null) => void; + signal?: AbortSignal; + onStreamActivity?: () => void; + timedOut: () => boolean; + onSessionExpired: () => void; + networkFailure: () => Error; +}; + +/** Execute the dashboard answer-stream HTTP contract without growing the dashboard component. */ +export async function requestAnswerStream({ + queryText, + filters, + queryMode, + selectedDocumentIds, + clientDemoMode, + authorizationHeader, + onProgress, + onEvidencePreview, + signal, + onStreamActivity, + timedOut, + onSessionExpired, + networkFailure, +}: AnswerRequestOptions) { + let response: Response; + try { + response = await fetch("/api/answer/stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(clientDemoMode ? {} : authorizationHeader), + }, + body: JSON.stringify({ + query: queryText, + documentIds: selectedDocumentIds.length > 0 ? selectedDocumentIds : undefined, + filters: compactScopeFilters(filters), + queryMode, + }), + signal, + }); + } catch (error) { + if (timedOut()) throw answerTimedOutError(); + if (isAbortError(error)) throw error; + throw networkFailure(); + } + + if (response.status === 401) { + onSessionExpired(); + throw makeSearchError("Search request was not authorized by the server.", 401, false); + } + if (!response.ok) { + throw await parseApiErrorResponse(response); + } + + try { + const payload = await readAnswerStream( + response, + onProgress, + undefined, + undefined, + onStreamActivity, + onEvidencePreview, + ); + return { + kind: "answer" as const, + query: queryText, + payload, + }; + } catch (error) { + if (timedOut()) throw answerTimedOutError(); + if (isAbortError(error)) throw error; + throw error; + } +} diff --git a/src/components/clinical-dashboard/search-utils.ts b/src/components/clinical-dashboard/search-utils.ts index 083081dd5e..494d3c3207 100644 --- a/src/components/clinical-dashboard/search-utils.ts +++ b/src/components/clinical-dashboard/search-utils.ts @@ -2,12 +2,22 @@ import { normalizeAnswerProgressEvent, type AnswerProgressUpdate, } from "@/components/clinical-dashboard/answer-progress"; +import { isAnswerStreamEventName, type VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; import type { RagAnswer } from "@/lib/types"; export { keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; export type AnswerPayload = RagAnswer & { demoMode?: boolean }; +export function evidencePreviewReconcilesWithFinal(preview: VerifiedEvidencePreviewUnit, finalPayload: AnswerPayload) { + const finalSourcesByIdentity = new Map( + finalPayload.sources.map((source) => [`${source.document_id}:${source.id}`, JSON.stringify(source)]), + ); + return preview.sources.every( + (source) => finalSourcesByIdentity.get(`${source.document_id}:${source.id}`) === JSON.stringify(source), + ); +} + const answerConfidenceValues = new Set(["high", "medium", "low", "unsupported"]); export function isAnswerPayload(value: unknown): value is AnswerPayload { @@ -59,9 +69,10 @@ function findSseSeparator(buffer: string) { export async function readAnswerStream( response: Response, onProgress: (progress: AnswerProgressUpdate) => void, - onToken?: (delta: string) => void, + _onToken?: (delta: string) => void, onRevising?: () => void, onActivity?: () => void, + onEvidencePreview?: (preview: VerifiedEvidencePreviewUnit | null) => void, ): Promise { if (!response.body) throw makeSearchError("Answer stream could not be opened.", undefined, true); @@ -69,6 +80,14 @@ export async function readAnswerStream( const decoder = new TextDecoder(); let buffer = ""; let pendingCompletion: AnswerProgressUpdate | null = null; + let lastVerifiedUnitSequence: number | null = null; + let evidencePreview: VerifiedEvidencePreviewUnit | null = null; + + function clearEvidencePreview() { + if (evidencePreview === null) return; + evidencePreview = null; + onEvidencePreview?.(null); + } function processEvent(block: string) { const lines = block.split(/\r?\n/); @@ -83,29 +102,36 @@ export async function readAnswerStream( if (dataLines.length === 0) return null; const data = parseSseData(dataLines); if (data === null) return null; + if (!isAnswerStreamEventName(event)) return null; if (event === "progress") { - const progress = normalizeAnswerProgressEvent(data); + const verifiedUnitWasProvided = + typeof data === "object" && + data !== null && + !Array.isArray(data) && + Object.prototype.hasOwnProperty.call(data, "verifiedUnit"); + const progress = normalizeAnswerProgressEvent(data, lastVerifiedUnitSequence); + if (verifiedUnitWasProvided && !progress?.verifiedUnit) clearEvidencePreview(); if (progress) { + const { verifiedUnit, ...publicProgress } = progress; + if (verifiedUnit) { + lastVerifiedUnitSequence = verifiedUnit.sequence; + if (verifiedUnit.kind === "evidence_preview") { + evidencePreview = verifiedUnit; + onEvidencePreview?.(verifiedUnit); + } + } if (progress.stage === "complete") { - pendingCompletion = progress; + pendingCompletion = publicProgress; } else { - onProgress(progress); + onProgress(publicProgress); if (progress.stage === "fallback") onRevising?.(); } } return null; } - if (event === "token") { - const delta = data && typeof data === "object" ? (data as { delta?: unknown }).delta : null; - if (typeof delta === "string" && delta) onToken?.(delta); - return null; - } - if (event === "revising") { - onRevising?.(); - return null; - } if (event === "error") { pendingCompletion = null; + clearEvidencePreview(); const message = data && typeof data === "object" ? (data as { error?: unknown }).error : null; const details = data && typeof data === "object" ? (data as { details?: { message?: unknown } | unknown }).details : null; @@ -130,8 +156,16 @@ export async function readAnswerStream( if (event === "final") { if (!isAnswerPayload(data)) { pendingCompletion = null; + clearEvidencePreview(); throw makeSearchError("Answer stream returned an invalid final payload.", 502, true); } + if (evidencePreview) { + // The final payload is authoritative in both outcomes. Reconciliation is + // deliberately evaluated before the preview is discarded so client tests + // guard the byte-identical subset contract without ever withholding final. + evidencePreviewReconcilesWithFinal(evidencePreview, data); + } + clearEvidencePreview(); if (pendingCompletion) { onProgress(pendingCompletion); pendingCompletion = null; @@ -142,30 +176,36 @@ export async function readAnswerStream( return null; } - while (true) { - const { value, done } = await reader.read(); - if (value && value.length > 0) onActivity?.(); - buffer += decoder.decode(value, { stream: !done }); - - let separator = findSseSeparator(buffer); - while (separator) { - const block = buffer.slice(0, separator.index).trim(); - buffer = buffer.slice(separator.index + separator.length); - const finalPayload = block ? processEvent(block) : null; - if (finalPayload) { - await reader.cancel().catch(() => undefined); - return finalPayload; + try { + while (true) { + const { value, done } = await reader.read(); + if (value && value.length > 0) onActivity?.(); + buffer += decoder.decode(value, { stream: !done }); + + let separator = findSseSeparator(buffer); + while (separator) { + const block = buffer.slice(0, separator.index).trim(); + buffer = buffer.slice(separator.index + separator.length); + const finalPayload = block ? processEvent(block) : null; + if (finalPayload) { + await reader.cancel().catch(() => undefined); + return finalPayload; + } + separator = findSseSeparator(buffer); } - separator = findSseSeparator(buffer); + + if (done) break; } - if (done) break; + const finalPayload = buffer.trim() ? processEvent(buffer.trim()) : null; + if (finalPayload) return finalPayload; + pendingCompletion = null; + clearEvidencePreview(); + throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true); + } catch (error) { + clearEvidencePreview(); + throw error; } - - const finalPayload = buffer.trim() ? processEvent(buffer.trim()) : null; - if (finalPayload) return finalPayload; - pendingCompletion = null; - throw makeSearchError("Answer stream ended before a final answer was received.", undefined, true); } export function isRetryableStatus(status: number) { diff --git a/src/lib/client-env.ts b/src/lib/client-env.ts index 13e8a1f51f..9ff30e70fb 100644 --- a/src/lib/client-env.ts +++ b/src/lib/client-env.ts @@ -3,6 +3,13 @@ export function isLocalNoAuthMode() { return process.env.NODE_ENV !== "production" && process.env.NEXT_PUBLIC_LOCAL_NO_AUTH === "true"; } +/** Build-time client gate for #100 Phase 1 evidence-preview rendering. */ +export function incrementalEvidencePreviewRenderingEnabled( + value = process.env.NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER, +) { + return value === "true"; +} + export function resolveClientDemoMode({ explicitDemoMode, authUnavailableFallback, diff --git a/tests/answer-evidence-preview.dom.test.tsx b/tests/answer-evidence-preview.dom.test.tsx new file mode 100644 index 0000000000..197eee929a --- /dev/null +++ b/tests/answer-evidence-preview.dom.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { AnswerEvidencePreview } from "@/components/clinical-dashboard/answer-evidence-preview"; +import { incrementalEvidencePreviewRenderingEnabled } from "@/lib/client-env"; +import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract"; + +function evidencePreview(sourceCount = 4): VerifiedEvidencePreviewUnit { + return { + schemaVersion: 1, + kind: "evidence_preview", + sequence: 0, + selectedContextCount: sourceCount, + sources: Array.from({ length: sourceCount }, (_, index) => ({ + id: `chunk-${index + 1}`, + document_id: `doc-${index + 1}`, + title: `Clinical guideline ${index + 1}`, + file_name: `guideline-${index + 1}.pdf`, + page_number: index + 2, + chunk_index: index, + section_heading: "Monitoring", + content: "Review the source passage and confirm the monitoring schedule before clinical use.", + image_ids: [], + similarity: 0.8, + images: [], + })), + }; +} + +describe("incremental answer evidence preview", () => { + it("keeps its client render flag off unless explicitly enabled", () => { + expect(incrementalEvidencePreviewRenderingEnabled(undefined)).toBe(false); + expect(incrementalEvidencePreviewRenderingEnabled("false")).toBe(false); + expect(incrementalEvidencePreviewRenderingEnabled("true")).toBe(true); + }); + + it("renders a bounded, non-live evidence region without presenting a completed answer", () => { + render(); + + const region = screen.getByTestId("answer-evidence-preview"); + expect( + within(region).getByRole("heading", { name: "Selected evidence — answer still being verified" }), + ).toBeTruthy(); + expect(within(region).getByText(/4 source passages selected/i)).toBeTruthy(); + expect(within(region).getAllByRole("link")).toHaveLength(3); + expect(region).not.toHaveAttribute("aria-live"); + expect(within(region).queryByText(/answer ready/i)).toBeNull(); + }); +}); diff --git a/tests/answer-progress.test.ts b/tests/answer-progress.test.ts index a4e2646945..bec5014a8c 100644 --- a/tests/answer-progress.test.ts +++ b/tests/answer-progress.test.ts @@ -5,7 +5,33 @@ import { normalizeAnswerProgressEvent, } from "../src/components/clinical-dashboard/answer-progress"; import { toPublicAnswerProgressEvent } from "../src/lib/answer-progress-public"; -import { readAnswerStream } from "../src/components/clinical-dashboard/search-utils"; +import { + evidencePreviewReconcilesWithFinal, + readAnswerStream, +} from "../src/components/clinical-dashboard/search-utils"; +import type { VerifiedEvidencePreviewUnit } from "../src/lib/answer-stream-contract"; + +const previewSource = { + id: "chunk-1", + document_id: "doc-1", + title: "Clozapine Monitoring", + file_name: "clozapine.pdf", + page_number: 3, + chunk_index: 1, + section_heading: "Monitoring", + content: "ANC thresholds and FBC monitoring schedule for clozapine.", + image_ids: [], + similarity: 0.82, + images: [], +}; + +const evidencePreview: VerifiedEvidencePreviewUnit = { + schemaVersion: 1, + kind: "evidence_preview", + sequence: 0, + sources: [previewSource], + selectedContextCount: 1, +}; describe("answer progress events", () => { it("keeps only safe, normalized Australian source counts at the public boundary", () => { @@ -108,16 +134,23 @@ describe("answer progress events", () => { ).toMatchObject({ resultCount: 2, selectedContextCount: undefined }); }); - it("shares one SSE parser across answer surfaces and commits completion only with a valid final answer", async () => { + it("parses only allowlisted SSE events and clears a verified preview when final becomes authoritative", async () => { const progress: string[] = []; const tokens: string[] = []; + const previews: Array = []; let revisions = 0; const body = [ - 'event: progress\ndata: {"stage":"retrieving","message":"private"}', + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}`, 'event: token\ndata: {"delta":"Draft"}', "event: revising\ndata: {}", 'event: progress\ndata: {"stage":"complete","message":"private","elapsedMs":1200}', - 'event: final\ndata: {"answer":"Grounded answer.","grounded":true,"confidence":"medium","citations":[],"sources":[]}', + `event: final\ndata: ${JSON.stringify({ + answer: "Grounded answer.", + grounded: true, + confidence: "medium", + citations: [], + sources: [previewSource], + })}`, "", ].join("\n\n"); @@ -128,14 +161,178 @@ describe("answer progress events", () => { () => { revisions += 1; }, + undefined, + (preview) => previews.push(preview), ); - expect(progress).toEqual(["retrieving", "complete"]); - expect(tokens).toEqual(["Draft"]); - expect(revisions).toBe(1); + expect(progress).toEqual(["ranking", "complete"]); + expect(tokens).toEqual([]); + expect(revisions).toBe(0); + expect(previews).toEqual([evidencePreview, null]); expect(answer.answer).toBe("Grounded answer."); }); + it("drops invalid, duplicate, and out-of-order verified units", async () => { + const previews: Array = []; + const previewByStage: Array<[string, VerifiedEvidencePreviewUnit | null]> = []; + let visiblePreview: VerifiedEvidencePreviewUnit | null = null; + const body = [ + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}`, + `event: progress\ndata: ${JSON.stringify({ stage: "generating", message: "private", verifiedUnit: evidencePreview })}`, + `event: progress\ndata: ${JSON.stringify({ + stage: "verifying", + message: "private", + verifiedUnit: { ...evidencePreview, schemaVersion: 2 }, + })}`, + `event: final\ndata: ${JSON.stringify({ + answer: "Grounded answer.", + grounded: true, + confidence: "medium", + citations: [], + sources: [previewSource], + })}`, + "", + ].join("\n\n"); + + await readAnswerStream( + new Response(body, { headers: { "Content-Type": "text/event-stream" } }), + (progress) => previewByStage.push([progress.stage, visiblePreview]), + undefined, + undefined, + undefined, + (preview) => { + visiblePreview = preview; + previews.push(preview); + }, + ); + + expect(previews).toEqual([evidencePreview, null]); + expect(previewByStage).toContainEqual(["ranking", evidencePreview]); + expect(previewByStage).toContainEqual(["generating", null]); + expect(previewByStage).toContainEqual(["verifying", null]); + }); + + it("discards a preview on stream error and starts sequence validation fresh for a retry", async () => { + const firstAttemptPreviews: Array = []; + const failedBody = [ + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}`, + 'event: error\ndata: {"error":"provider unavailable","status":503}', + "", + ].join("\n\n"); + + await expect( + readAnswerStream( + new Response(failedBody, { headers: { "Content-Type": "text/event-stream" } }), + () => undefined, + undefined, + undefined, + undefined, + (preview) => firstAttemptPreviews.push(preview), + ), + ).rejects.toThrow("provider unavailable"); + expect(firstAttemptPreviews).toEqual([evidencePreview, null]); + + const retryPreviews: Array = []; + const retryBody = [ + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}`, + `event: final\ndata: ${JSON.stringify({ + answer: "Retry succeeded.", + grounded: true, + confidence: "medium", + citations: [], + sources: [previewSource], + })}`, + "", + ].join("\n\n"); + await readAnswerStream( + new Response(retryBody, { headers: { "Content-Type": "text/event-stream" } }), + () => undefined, + undefined, + undefined, + undefined, + (preview) => retryPreviews.push(preview), + ); + expect(retryPreviews).toEqual([evidencePreview, null]); + }); + + it("discards a preview when the response stream is cancelled", async () => { + const previews: Array = []; + const encoder = new TextEncoder(); + let previewDelivered = false; + const body = new ReadableStream({ + pull(controller) { + if (!previewDelivered) { + previewDelivered = true; + controller.enqueue( + encoder.encode( + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}\n\n`, + ), + ); + return; + } + controller.error(new DOMException("Cancelled", "AbortError")); + }, + }); + + await expect( + readAnswerStream( + new Response(body, { headers: { "Content-Type": "text/event-stream" } }), + () => undefined, + undefined, + undefined, + undefined, + (preview) => previews.push(preview), + ), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(previews).toEqual([evidencePreview, null]); + }); + + it("requires byte-identical preview sources to reconcile with final", () => { + const finalPayload = { + answer: "Grounded answer.", + grounded: true, + confidence: "medium" as const, + citations: [], + sources: [previewSource], + }; + + expect(evidencePreviewReconcilesWithFinal(evidencePreview, finalPayload)).toBe(true); + expect( + evidencePreviewReconcilesWithFinal( + { ...evidencePreview, sources: [{ ...previewSource, content: "Changed source text." }] }, + finalPayload, + ), + ).toBe(false); + }); + + it("discards a mismatched preview while retaining the authoritative final payload", async () => { + const previews: Array = []; + const authoritativeSource = { ...previewSource, content: "Authoritative final source text." }; + const body = [ + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}`, + `event: final\ndata: ${JSON.stringify({ + answer: "Authoritative final answer.", + grounded: true, + confidence: "medium", + citations: [], + sources: [authoritativeSource], + })}`, + "", + ].join("\n\n"); + + const finalPayload = await readAnswerStream( + new Response(body, { headers: { "Content-Type": "text/event-stream" } }), + () => undefined, + undefined, + undefined, + undefined, + (preview) => previews.push(preview), + ); + + expect(previews).toEqual([evidencePreview, null]); + expect(finalPayload.sources).toEqual([authoritativeSource]); + }); + it("fails closed when a shared answer stream ends without a valid final payload", async () => { const response = new Response('event: progress\ndata: {"stage":"complete","message":"private"}\n\n'); diff --git a/tests/answer-request.test.ts b/tests/answer-request.test.ts new file mode 100644 index 0000000000..e2c9bc2dcd --- /dev/null +++ b/tests/answer-request.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { requestAnswerStream } from "../src/components/clinical-dashboard/answer-request"; +import type { VerifiedEvidencePreviewUnit } from "../src/lib/answer-stream-contract"; + +const previewSource = { + id: "chunk-1", + document_id: "doc-1", + title: "Clozapine Monitoring", + file_name: "clozapine.pdf", + page_number: 3, + chunk_index: 1, + section_heading: "Monitoring", + content: "ANC thresholds and FBC monitoring schedule for clozapine.", + image_ids: [], + similarity: 0.82, + images: [], +}; + +const evidencePreview: VerifiedEvidencePreviewUnit = { + schemaVersion: 1, + kind: "evidence_preview", + sequence: 0, + sources: [previewSource], + selectedContextCount: 1, +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("requestAnswerStream", () => { + it("preserves the authenticated request body and forwards only verified preview events", async () => { + const streamBody = [ + `event: progress\ndata: ${JSON.stringify({ stage: "ranking", message: "private", verifiedUnit: evidencePreview })}`, + `event: final\ndata: ${JSON.stringify({ + answer: "Grounded answer.", + grounded: true, + confidence: "medium", + citations: [], + sources: [previewSource], + })}`, + "", + ].join("\n\n"); + const fetchMock = vi.fn(async () => new Response(streamBody, { headers: { "Content-Type": "text/event-stream" } })); + vi.stubGlobal("fetch", fetchMock); + const previews: Array = []; + + const result = await requestAnswerStream({ + queryText: "clozapine monitoring", + filters: { medications: ["clozapine"] }, + queryMode: "auto", + selectedDocumentIds: ["doc-1"], + clientDemoMode: false, + authorizationHeader: { authorization: "Bearer test-token" }, + onProgress: () => undefined, + onEvidencePreview: (preview) => previews.push(preview), + timedOut: () => false, + onSessionExpired: vi.fn(), + networkFailure: () => new Error("network failure"), + }); + + const [input, init] = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit]; + expect(input).toBe("/api/answer/stream"); + expect(init.headers).toEqual({ + "Content-Type": "application/json", + authorization: "Bearer test-token", + }); + expect(JSON.parse(String(init.body))).toEqual({ + query: "clozapine monitoring", + documentIds: ["doc-1"], + filters: { medications: ["clozapine"] }, + queryMode: "auto", + }); + expect(previews).toEqual([evidencePreview, null]); + expect(result).toMatchObject({ kind: "answer", query: "clozapine monitoring" }); + expect(result.payload.answer).toBe("Grounded answer."); + }); + + it("expires the client session before surfacing a 401 answer response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 401 })), + ); + const onSessionExpired = vi.fn(); + + await expect( + requestAnswerStream({ + queryText: "clozapine monitoring", + filters: {}, + queryMode: "auto", + selectedDocumentIds: [], + clientDemoMode: true, + authorizationHeader: {}, + onProgress: () => undefined, + onEvidencePreview: () => undefined, + timedOut: () => false, + onSessionExpired, + networkFailure: () => new Error("network failure"), + }), + ).rejects.toMatchObject({ status: 401, retryable: false }); + expect(onSessionExpired).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/client-secret-surface.test.ts b/tests/client-secret-surface.test.ts index e84748dad2..4b0b30d0c1 100644 --- a/tests/client-secret-surface.test.ts +++ b/tests/client-secret-surface.test.ts @@ -19,7 +19,11 @@ const SRC_ROOT = join(ROOT, "src"); const SERVER_ENV = join(SRC_ROOT, "lib", "env.ts"); const PUBLIC_ENV = join(SRC_ROOT, "lib", "client-env.ts"); const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; -const PUBLIC_ENV_KEYS = new Set(["NODE_ENV", "NEXT_PUBLIC_LOCAL_NO_AUTH"]); +const PUBLIC_ENV_KEYS = new Set([ + "NODE_ENV", + "NEXT_PUBLIC_LOCAL_NO_AUTH", + "NEXT_PUBLIC_RAG_INCREMENTAL_EVIDENCE_PREVIEW_RENDER", +]); const importCache = new Map(); function sourceFiles(dir: string): string[] {