Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand Down
96 changes: 45 additions & 51 deletions src/components/ClinicalDashboard.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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";
Expand All@@ -100,7 +107,6 @@ import {
import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context";
import {
answerReferencesDocument,
answerTimedOutError,
applyRenamedDocumentToAnswer,
compactScopeFilters,
hasActiveIndexingWork,
Expand DownExpand Up@@ -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 {
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -438,6 +444,7 @@ export function ClinicalDashboard({
setSourceGovernanceWarnings([]);
setError(null);
setAnswerProgress(null);
setAnswerEvidencePreview(null);
setDifferentialEvidenceQuery(null);
}, [resetAnswerThread]);
const [scopeFilters, setScopeFilters] = useState<SearchScopeFilters>(initialSearchNavigationContext.scopeFilters);
Expand All@@ -450,6 +457,7 @@ export function ClinicalDashboard({
const [answerProgress, setAnswerProgress] = useState<string | null>(null);
const [answerProgressEvents, setAnswerProgressEvents] = useState<TimedAnswerProgressUpdate[]>([]);
const [answerProgressStartedAt, setAnswerProgressStartedAt] = useState<number | null>(null);
const [answerEvidencePreview, setAnswerEvidencePreview] = useState<VerifiedEvidencePreviewUnit | null>(null);
const [answerLifecycle, dispatchAnswerLifecycle] = useReducer(answerLifecycleReducer, initialAnswerLifecycle);
const [error, setError] = useState<string | null>(null);
// Companion state for `error`, used to pick the right recovery UI (retry vs.
Expand DownExpand Up@@ -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<T>(
Expand DownExpand Up@@ -1774,6 +1755,7 @@ export function ClinicalDashboard({
setAnswerProgress(null);
setAnswerProgressEvents([]);
setAnswerProgressStartedAt(null);
setAnswerEvidencePreview(null);
dispatchAnswerLifecycle({ type: "cancel" });
}

Expand DownExpand Up@@ -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);
Expand All@@ -1952,6 +1938,7 @@ export function ClinicalDashboard({
setSearchFacets(null);
setSearchScope(null);
setSourceGovernanceWarnings([]);
setAnswerEvidencePreview(null);
setAnswerViewMode("high_yield");
if (isAnswerRequest) {
const startedAt = Date.now();
Expand DownExpand Up@@ -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,
);
Expand DownExpand Up@@ -3660,6 +3650,10 @@ export function ClinicalDashboard({
<SearchProgressBanner message={answerProgress} onStop={stopSearch} />
) : null)}

{activeModeResultKind === "answer" && loading && answerEvidencePreview ? (
<AnswerEvidencePreview preview={answerEvidencePreview} />
) : null}

{showUniversalAlsoMatches &&
(activeModeResultKind === "tools" ||
activeModeResultKind === "documents" ||
Expand Down
67 changes: 67 additions & 0 deletions src/components/clinical-dashboard/answer-evidence-preview.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<section
aria-labelledby="answer-evidence-preview-title"
data-testid="answer-evidence-preview"
className={cn(panelSubtle, "overflow-hidden")}
>
<div className="border-b border-[color:var(--border)] bg-[color:var(--surface-subtle)] px-3 py-3 sm:px-4">
<div className="flex items-start gap-2.5">
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]">
<ShieldCheck aria-hidden="true" className="h-4 w-4" />
</span>
<div className="min-w-0">
<h2 id="answer-evidence-preview-title" className="text-sm font-semibold text-[color:var(--text-heading)]">
Selected evidence — answer still being verified
</h2>
<p className={cn("mt-0.5 text-xs leading-5", textMuted)}>
{preview.selectedContextCount} source passage{preview.selectedContextCount === 1 ? "" : "s"} selected. The
final answer and source list remain authoritative.
</p>
</div>
</div>
</div>

<div className="grid gap-2 p-3 sm:grid-cols-3 sm:p-4">
{visibleSources.map((source, index) => {
const title = cleanDisplayTitle(source.title);
const snippet = compactSourceSnippet(source.content, { dropTitle: title });
return (
<Link
key={`${source.document_id}:${source.id}`}
href={sourceResultHref(source)}
aria-label={`Open selected evidence ${index + 1}: ${title}`}
className="min-w-0 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 transition hover:border-[color:var(--border-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"
>
<div className="flex items-start gap-2">
<FileText aria-hidden="true" className="mt-0.5 h-4 w-4 shrink-0 text-[color:var(--clinical-accent)]" />
<div className="min-w-0">
<p className="line-clamp-2 text-sm font-semibold text-[color:var(--text-heading)]">{title}</p>
<p className={cn("mt-0.5 text-xs", textMuted)}>
p.{source.page_number ?? "n/a"}
{source.section_heading ? ` · ${cleanDisplayTitle(source.section_heading)}` : ""}
</p>
</div>
</div>
{snippet ? <p className={cn("mt-2 line-clamp-2 text-xs leading-5", textMuted)}>{snippet}</p> : null}
</Link>
);
})}
</div>
</section>
);
}
10 changes: 9 additions & 1 deletion src/components/clinical-dashboard/answer-progress.ts
Original file line numberDiff line numberDiff line change
@@ -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 };
Expand DownExpand Up@@ -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 };
Expand All@@ -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),
Expand Down
Loading
Loading