diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index c822dffea6..4d2e392bc6 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -3619,6 +3619,11 @@ "section": "rag-improvement", "catalogued": true }, + { + "path": "docs/decisions/2026-10-17-answer-page-tidy-up.md", + "section": "decisions", + "catalogued": true + }, { "path": "docs/superpowers/specs/2026-08-20-care-plan-design.md", "section": "superpowers", diff --git a/docs/decisions/2026-10-17-answer-page-tidy-up.md b/docs/decisions/2026-10-17-answer-page-tidy-up.md new file mode 100644 index 0000000000..d51e816dfc --- /dev/null +++ b/docs/decisions/2026-10-17-answer-page-tidy-up.md @@ -0,0 +1,8 @@ +# Decision: Answer page tidy-up and a shorter overseas notice + +- **Status:** decided 2026-10-17 +- **Source:** `src/components/clinical-dashboard/answer-provider-notice.tsx`, find "AnswerSummaryUnavailableNotice" + +The owner approved a mockup of the Answer page and asked for it to be built. The overseas notice under an answer now reads "Sent to OpenAI overseas. Don't include patient details." and is centred. The line no longer shows on the Answer home at all ([the home-page decision](2026-10-17-no-overseas-line-on-home-pages.md)), so it only appears once a question has been sent. When the model could not write the answer, an amber "AI summary unavailable" line sits directly under that notice instead of a sentence above the question. Other degraded reasons, which say something about the evidence, keep their sentence above the answer. The limitations chip drops its count when it already says "Source-only" or "Review due", and the support and limitations chips share one square shape and size. "How this answer was built" moved under the answer's actions, and "Also in your library" became a plain row with its count shown at every width. + +If this summary and the source ever differ, the source wins. diff --git a/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md b/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md index e278c408d6..629ec74d3a 100644 --- a/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md +++ b/docs/decisions/2026-10-17-no-overseas-line-on-home-pages.md @@ -5,7 +5,8 @@ The owner asked for the line "Questions are sent to an overseas AI service (OpenAI) to search and write answers. Don't include patient details." to be removed from the home pages. The Answer home was the only -one showing it. It still shows under each answer, the composer still warns against patient-identifiable +one showing it. It still shows under each answer, in the shorter wording of +[the Answer page tidy-up](2026-10-17-answer-page-tidy-up.md), the composer still warns against patient-identifiable information, and the privacy page still says questions may go to the OpenAI API outside Australia. This replaces the 13 Oct 2026 wish to show the line on the Answer home before the first question. diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d850e24520..64eca09ca0 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -71,7 +71,10 @@ import { SystemNotice, DegradedNoticeFrame } from "@/components/clinical-dashboa import { resolveModeHomeCanvasClass } from "@/components/clinical-dashboard/mode-home-canvas"; import { sanitizeAnswerDisplayText } from "@/components/clinical-dashboard/display-text"; import { AnswerCancelledNotice } from "@/components/clinical-dashboard/answer-cancelled-notice"; -import { AnswerProviderNotice } from "@/components/clinical-dashboard/answer-provider-notice"; +import { + AnswerProviderNotice, + AnswerSummaryUnavailableNotice, +} from "@/components/clinical-dashboard/answer-provider-notice"; import { isPreformattedGroundedAnswer } from "@/components/clinical-dashboard/answer-content"; import { AnswerProgress, @@ -427,6 +430,8 @@ function ClinicalDashboardContent({ const [loading, setLoading] = useState(false); const [answerProgress, setAnswerProgress] = useState(null); const [answerProgressEvents, setAnswerProgressEvents] = useState([]); + const [answerBuildEvents, setAnswerBuildEvents] = useState([]); + const answerProgressEventsRef = useRef([]); const [answerProgressStartedAt, setAnswerProgressStartedAt] = useState(null); const [answerEvidencePreview, setAnswerEvidencePreview] = useState(null); const [answerLifecycle, dispatchAnswerLifecycle] = useReducer(answerLifecycleReducer, initialAnswerLifecycle); @@ -442,6 +447,7 @@ function ClinicalDashboardContent({ setSourceGovernanceWarnings([]); setError(null); setAnswerProgress(null); + setAnswerBuildEvents([]); setAnswerEvidencePreview(null); setDifferentialEvidenceQuery(null); }, [ @@ -1177,6 +1183,7 @@ function ClinicalDashboardContent({ setLoading(false); setAnswerProgress(null); setAnswerProgressEvents([]); + answerProgressEventsRef.current = []; setAnswerProgressStartedAt(null); setAnswerEvidencePreview(null); dispatchAnswerLifecycle({ type: "cancel" }); @@ -1353,20 +1360,23 @@ function ClinicalDashboardContent({ const onAnswerProgress = (progress: AnswerProgressUpdate) => { if (!requestIsCurrent()) return; setAnswerProgress(progress.message); - setAnswerProgressEvents((current) => { - const latest = current.at(-1); - if ( - latest?.stage === progress.stage && - latest.message === progress.message && - latest.resultCount === progress.resultCount && - latest.selectedContextCount === progress.selectedContextCount && - latest.australianSourceCount === progress.australianSourceCount && - latest.waSourceCount === progress.waSourceCount - ) { - return current; - } - return [...current, { ...progress, receivedAt: Date.now() }].slice(-16); - }); + // Work from the ref, not a lazy updater: the stream sends "complete" just before it returns + // the final payload, and the accepted answer copies this ref before React renders again. + const current = answerProgressEventsRef.current; + const latest = current.at(-1); + if ( + latest?.stage === progress.stage && + latest.message === progress.message && + latest.resultCount === progress.resultCount && + latest.selectedContextCount === progress.selectedContextCount && + latest.australianSourceCount === progress.australianSourceCount && + latest.waSourceCount === progress.waSourceCount + ) { + return; + } + const next = [...current, { ...progress, receivedAt: Date.now() }].slice(-16); + answerProgressEventsRef.current = next; + setAnswerProgressEvents(next); }; const onAnswerEvidencePreview = (preview: VerifiedEvidencePreviewUnit | null) => { if (!requestIsCurrent()) return; @@ -1395,16 +1405,19 @@ function ClinicalDashboardContent({ if (isAnswerRequest) { const startedAt = Date.now(); setAnswerProgressStartedAt(startedAt); - setAnswerProgressEvents([ + const initialProgressEvents = [ { - stage: "scoping", + stage: "scoping" as const, message: "Preparing the clinical search scope.", receivedAt: startedAt, }, - ]); + ]; + answerProgressEventsRef.current = initialProgressEvents; + setAnswerProgressEvents(initialProgressEvents); setAnswerProgress("Preparing the clinical search scope."); } else { setAnswerProgressStartedAt(null); + answerProgressEventsRef.current = []; setAnswerProgressEvents([]); onProgress(modeSearch.progressLabel); } @@ -1529,6 +1542,12 @@ function ClinicalDashboardContent({ }), ); } + if (successfulPayload.kind === "answer") { + // Keep the history that produced the accepted answer. A later failed + // request may replace the active progress events without replacing + // the answer shown on screen. + setAnswerBuildEvents(answerProgressEventsRef.current); + } applySearchResult(successfulPayload, trimmedQuery, !replaceExistingAnswer); if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery); if (successfulPayload.kind === "answer") { @@ -2982,13 +3001,16 @@ function ClinicalDashboardContent({ onScopeDocument={handleScopeDocument} restoredSavedAt={restoredAnswerSavedAt} onRecheckAnswer={handleRecheckRestoredAnswer} + buildEvents={answerBuildEvents} /> {/* Once per page, under the latest answer: where a question goes (audit S3). */} {explicitDemoMode || answer.demoMode === true || answer.fallbackMode === "non_production_demo" ? null : ( - + )} + {/* Directly under it: the model could not write this answer (owner decision, 17 Oct 2026). */} + ) : null ) : null} diff --git a/src/components/clinical-dashboard/answer-build-details.tsx b/src/components/clinical-dashboard/answer-build-details.tsx new file mode 100644 index 0000000000..d984d952a7 --- /dev/null +++ b/src/components/clinical-dashboard/answer-build-details.tsx @@ -0,0 +1,46 @@ +import { ChevronDown, ListTree } from "lucide-react"; + +import { + answerProgressDisplayMessage, + answerProgressTookUnusualRoute, + type TimedAnswerProgressUpdate, +} from "@/components/clinical-dashboard/answer-progress"; + +/** + * "How this answer was built", as a row under the answer's actions (owner decision, 17 Oct 2026). + * It used to sit above the question as a small triangle link, which put a footnote first. + * + * Still only offered when the answer did not take the ordinary route. A routine answer has + * nothing to disclose, and the retired panel offered the same five stages every time. + */ +export function AnswerBuildDetails({ events }: { events: TimedAnswerProgressUpdate[] }) { + if (events.at(-1)?.stage !== "complete" || !answerProgressTookUnusualRoute(events)) return null; + const details = events + .map((event) => ({ ...event, displayMessage: answerProgressDisplayMessage(event) })) + .filter((event, index, all) => index === 0 || event.displayMessage !== all[index - 1]?.displayMessage) + .slice(-8); + return ( +
+ + + How this answer was built + +
    + {details.map((event, index) => ( +
  1. + {event.displayMessage} +
  2. + ))} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/answer-provider-notice.tsx b/src/components/clinical-dashboard/answer-provider-notice.tsx index 9e2de16cc1..85131c846b 100644 --- a/src/components/clinical-dashboard/answer-provider-notice.tsx +++ b/src/components/clinical-dashboard/answer-provider-notice.tsx @@ -1,10 +1,18 @@ -import { Globe } from "lucide-react"; +import { Globe, TriangleAlert } from "lucide-react"; import { cn, textMuted } from "@/components/ui-primitives"; +import { answerUsesDegradedMode, type AnswerStateInput } from "@/components/ui/answer-state"; +import { isProviderGenerationFallbackCode, publicFallbackReason } from "@/lib/rag/rag-fallback-reason"; -/** The overseas-processing line under an answer (audit S3, 2026-10-13). Draft wording: owner approval pending. */ -export const ANSWER_PROVIDER_NOTICE_TEXT = - "Questions are sent to an overseas AI service (OpenAI) to search and write answers. Don't include patient details."; +/** + * The overseas-processing line under an answer (audit S3, 2026-10-13). Shortened to one phone line by the + * owner, 17 Oct 2026 (docs/decisions/2026-10-17-answer-page-tidy-up.md). It still names the service, says + * it is overseas and asks for no patient details. + */ +export const ANSWER_PROVIDER_NOTICE_TEXT = "Sent to OpenAI overseas. Don't include patient details."; + +/** The visible words when the model could not write the answer and the source text is shown instead. */ +export const ANSWER_SUMMARY_UNAVAILABLE_TEXT = "AI summary unavailable"; /** * One quiet line under the latest answer. It was also on the Answer home before the first question @@ -30,3 +38,66 @@ export function AnswerProviderNotice({ className }: { className?: string }) {

); } + +/** + * The public reason when this answer is degraded only because the model could not write it, else null. + * One test for both places, so the sentence is never shown above the answer and below it at once. + */ +export function answerSummaryUnavailableReason(answer: AnswerStateInput): string | null { + const code = answer.fallbackReasonCode; + if (!answerUsesDegradedMode(answer) || !code || !isProviderGenerationFallbackCode(code)) return null; + // A retrieval deadline also reports provider_timeout, but no evidence was confirmed. The client + // payload drops routingReason, so the empty source list is the guard that holds in the browser: + // with nothing confirmed, the warning stays above the answer. + if (answer.routingReason === "retrieval_deadline_exceeded" || !answer.sources?.length) return null; + return publicFallbackReason(code); +} + +export const ANSWER_NO_CONFIRMED_SOURCES_TEXT = + "Source evidence could not be confirmed, so no verified passages are shown. Please try again."; + +/** + * The sentence above a degraded answer. A provider code's own wording promises a verified + * source-backed portion, which is false when no source came back (a retrieval deadline, for one). + */ +export function answerDegradationNoteText(answer: AnswerStateInput): string { + const code = answer.fallbackReasonCode ?? "unknown"; + if (isProviderGenerationFallbackCode(code) && !answer.sources?.length) return ANSWER_NO_CONFIRMED_SOURCES_TEXT; + return publicFallbackReason(code); +} + +/** + * Amber line directly under the overseas notice when the model could not write the answer + * (owner decision, 17 Oct 2026). It replaced the grey sentence above the question, which pushed the + * question and the answer down. Only the provider failures land here: every other degraded reason + * says something about the evidence and keeps its full sentence above the answer. + * + * The visible words are short. The full public reason stays as screen-reader text, so a screen + * reader still hears why and that the verified source-backed portion is what is shown. + */ +export function AnswerSummaryUnavailableNotice({ + answer, + className, +}: { + answer: AnswerStateInput; + className?: string; +}) { + const reason = answerSummaryUnavailableReason(answer); + if (!reason) return null; + return ( +

+

+ ); +} diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 9d26b1977c..78f189b877 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -43,7 +43,12 @@ import { answerSurface, cn } from "@/components/ui-primitives"; import { isCurrencyReviewWarning, type AnswerRenderModel } from "@/lib/answer-render-policy"; import { demoAnswerDisclosure } from "@/lib/answer-client-payload"; import { formatRestoredAnswerAge } from "@/lib/answer-thread-storage"; -import { publicFallbackReason } from "@/lib/rag/rag-fallback-reason"; +import { + answerDegradationNoteText, + answerSummaryUnavailableReason, +} from "@/components/clinical-dashboard/answer-provider-notice"; +import { AnswerBuildDetails } from "@/components/clinical-dashboard/answer-build-details"; +import type { TimedAnswerProgressUpdate } from "@/components/clinical-dashboard/answer-progress"; import type { ClientBestSourceRecommendation, ClientQuoteCard, @@ -78,7 +83,7 @@ import { compactVerificationWordingFor, VerificationNotice } from "@/components/ * is the micro floor for count bubbles and legends. * * Nothing about what these state has changed: same words, same amber tokens, same icons. */ -const chipShape = "inline-flex min-h-6 items-center gap-1 rounded-full border px-2.5 text-xs font-semibold"; +const chipShape = "inline-flex min-h-6 items-center gap-1 rounded-md border px-2 text-xs font-semibold"; /** * An interactive chip is a small pill inside a full-size button, not a small * button. `before:-inset-y-*` hit expansion draws the same 48px region and is @@ -127,6 +132,7 @@ function StagedAnswerResultSurfaceImpl({ restoredSavedAt = null, restoredNow, onRecheckAnswer, + buildEvents, }: { answer: ClientRagAnswerPayload & { interactionId?: string }; query: string; @@ -160,6 +166,8 @@ function StagedAnswerResultSurfaceImpl({ restoredSavedAt?: number | null; /** Optional clock override for deterministic age formatting in tests. */ restoredNow?: number; + /** Progress events for "How this answer was built", shown above the library row once generation ends. */ + buildEvents?: TimedAnswerProgressUpdate[]; /** Re-runs the current answer query against the live source library. */ onRecheckAnswer?: () => void; }) { @@ -390,7 +398,7 @@ function StagedAnswerResultSurfaceImpl({ > 0 || answerReviewDue || sourceOnlyAnswer ? ( <> @@ -480,7 +494,7 @@ function StagedAnswerResultSurfaceImpl({ )} >