From e87c446854c5089f16ef0fbda9d69eea59f8bf91 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:27:45 +0800 Subject: [PATCH 01/11] fix(rag): honor preformatted answers, render high-yield bold, guard safety caveats + retrieval telemetry Client display fixes (offline-proven, no retrieval-ranking impact): - F1: the client now honors the server `preformatted` flag, so document-support-list and table/visual answers are no longer re-mangled by the prose sanitizer on display (document names and facility codes were being deleted). Adds a lossless normalizePreformattedDisplayText and threads the flag through the display chain. - F3: high-yield **bold** now reaches the rendered answer via SafeBoldText (display-only preserveBold; the shared server answer-gen path is unchanged), and the copy/paste clinical draft no longer leaks literal `**`. - F4: the compact primary-answer cap never drops a safety-signal fragment (withhold/stop/threshold/escalate/...); byte-identical when none is present. Retrieval observability (additive, retrieval results byte-identical): - F8: lexical RPC errors (match_document_chunks_text, match_document_table_facts_text, match_documents_for_query) now record hybrid_rpc_errors telemetry before returning empty, instead of silently degrading to zero candidates with no signal. Infra: - F7: replay-safe migration documenting the retrieval owner-filter drift with a verified, owner-runnable byte-perfect codification procedure. Live already gates all 8 primary retrieval RPCs with the fail-closed retrieval_owner_matches; the gap is committed-source reproducibility only. Verification: typecheck, lint, format, full vitest (1655 passed), eval:rag:offline, and the @critical demo answer-flow E2E all green. Live eval:retrieval:quality 36/36. Co-Authored-By: Claude Opus 4.8 --- src/components/ClinicalDashboard.tsx | 24 +++- .../clinical-dashboard/answer-content.tsx | 64 ++++++++--- .../answer-result-surface.tsx | 5 +- .../clinical-dashboard/display-text.ts | 15 ++- .../clinical-dashboard/evidence-panels.tsx | 4 +- .../clinical-dashboard/output-panel.tsx | 4 +- src/lib/answer-render-policy.ts | 6 +- src/lib/rag-answer-text.ts | 16 ++- src/lib/rag.ts | 10 ++ src/lib/source-text-sanitizer.ts | 16 ++- ...forward_codify_retrieval_owner_matches.sql | 107 ++++++++++++++++++ tests/answer-render-policy.test.ts | 12 ++ tests/display-text.test.ts | 25 ++++ tests/rag-answer-text.test.ts | 14 +++ tests/rag-offline-answer.test.ts | 41 ++++++- tests/source-text-sanitizer.test.ts | 14 +++ 16 files changed, 341 insertions(+), 36 deletions(-) create mode 100644 supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index b0635e586..fa47b66bc 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -519,7 +519,11 @@ function PriorAnswerTurnSurface({ () => buildAnswerRenderModel(turn.answer, { sources: turn.sources }), [turn.answer, turn.sources], ); - const safeText = useMemo(() => sanitizeAnswerDisplayText(turn.answer.answer), [turn.answer.answer]); + const turnPreformatted = Boolean(turn.answer.preformatted && turn.answer.grounded); + const safeText = useMemo( + () => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }), + [turn.answer.answer, turnPreformatted], + ); const sourceCount = renderModel.primarySources.length || turn.sources.length || @@ -558,7 +562,8 @@ function PriorAnswerTurnSurface({ ) : ( <> new Map(sources.map((source) => [source.id, source])), [sources]); - const safeAnswerText = useMemo(() => sanitizeAnswerDisplayText(answer?.answer ?? ""), [answer?.answer]); + const answerPreformatted = Boolean(answer?.preformatted && answer?.grounded); + const safeAnswerText = useMemo( + () => sanitizeAnswerDisplayText(answer?.answer ?? "", { preformatted: answerPreformatted }), + [answer?.answer, answerPreformatted], + ); const answerFollowUpSuggestions = useMemo(() => { if (!answer || !latestAnswerQuery) return []; const priorQueries = [...priorAnswerTurns.map((turn) => turn.query), latestAnswerQuery]; @@ -2859,7 +2868,11 @@ export function ClinicalDashboard({ return (answer?.answerSections ?? []) .map((section) => { const heading = sanitizeDisplayText(section.heading, { minLength: 1, minTokens: 1 }); - const body = sanitizeAnswerDisplayText(section.body, { minLength: 8, minTokens: 2 }); + const body = sanitizeAnswerDisplayText(section.body, { + minLength: 8, + minTokens: 2, + preformatted: answerPreformatted, + }); if (!heading || !body) return null; const citationSources: SearchResult[] = []; @@ -2880,7 +2893,7 @@ export function ClinicalDashboard({ }; }) .filter((section): section is AnswerSection & { citationSources: SearchResult[] } => section !== null); - }, [answer?.answerSections, sourceLookup]); + }, [answer?.answerSections, answerPreformatted, sourceLookup]); const answerEvidenceMapRows = useMemo(() => { if (!answerRenderModel?.allowedBlocks.includes("evidenceMap")) return []; return evidenceMapRowsFromRenderModel(answerRenderModel).slice(0, answerRenderModel.trust === "high" ? 8 : 6); @@ -3607,7 +3620,6 @@ export function ClinicalDashboard({ can render it. + preserveBold?: boolean; +}; + +// Fragments carrying a safety-critical signal must never be dropped by the +// compact 3-fragment / 85-word cap — a withhold/threshold/escalation caveat +// hidden from the primary prose is a clinical-safety regression. +const primaryAnswerSafetySignalPattern = + /\b(?:withhold|withheld|stop|cease|hold|threshold|escalat|urgent|red\s*zone|amber|immediately|do not|contraindicat|toxic)\b/i; + +export function plainAnswerText(value: string, options: AnswerDisplayTextOptions = {}) { + // clinicalProseUsefulness runs the source-noise stripper, so preformatted + // answers bypass it and go straight to the lossless display path. + const base = options.preformatted ? value : clinicalProseUsefulness(value).text || value; + return sanitizeAnswerDisplayText(base, { + minLength: 8, + minTokens: 2, + preformatted: options.preformatted, + preserveBold: options.preserveBold, + }) .replace(/(?:\s*\n\s*)?Synthetic demo only:.*$/i, "") .trim(); } -function primaryAnswerDisplayText(value: string) { - const cleaned = plainAnswerText(value); +function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) { + const cleaned = plainAnswerText(value, options); + // Deterministic preformatted answers are already concise and display-ready; + // the fragment-level usefulness pass below would re-strip the very names/codes + // the preformatted path just preserved, so return them as-is. + if (options.preformatted) return cleaned; const fragments = cleaned .split(/\r?\n+/) .flatMap((line: string) => @@ -249,13 +275,21 @@ function primaryAnswerDisplayText(value: string) { return useful.useful || fragment.split(/\s+/).length >= 8; }); const uniqueFragments = Array.from(new Set(fragments)); - const selected = uniqueFragments.slice(0, 3).join(" "); - const words = selected.split(/\s+/).filter(Boolean); - if (words.length <= 85) return selected || cleaned; - return `${words - .slice(0, 85) - .join(" ") - .replace(/[;,:-]\s*$/, "")}...`; + // Compact head (unchanged when no later fragment is safety-critical), plus any + // safety-signal fragment beyond the head appended in full so the cap can never + // hide it. When safetyTail is empty this is byte-identical to the prior cap. + const head = uniqueFragments.slice(0, 3); + const safetyTail = uniqueFragments.slice(3).filter((fragment) => primaryAnswerSafetySignalPattern.test(fragment)); + const headWords = head.join(" ").split(/\s+/).filter(Boolean); + const headText = + headWords.length <= 85 + ? head.join(" ") + : `${headWords + .slice(0, 85) + .join(" ") + .replace(/[;,:-]\s*$/, "")}...`; + const selected = [headText, ...safetyTail].filter(Boolean).join(" "); + return selected || cleaned; } // One compact "Sources" pill in every state: the amber Source-only pill and the @@ -535,6 +569,7 @@ function SourcePreviewContent({ export function NaturalLanguageAnswer({ text, + preformatted = false, sourceCount, sourceOnly, bestSource, @@ -543,7 +578,10 @@ export function NaturalLanguageAnswer({ copied, onCopy, }: { + // Raw answer text (server bold intact); this component owns display + // sanitization so can render the high-yield emphasis. text: string; + preformatted?: boolean; sourceCount: number; sourceOnly: boolean; bestSource: BestSourceRecommendation | null; @@ -563,7 +601,7 @@ export function NaturalLanguageAnswer({ if (copySourceQuoteTimerRef.current !== null) window.clearTimeout(copySourceQuoteTimerRef.current); }; }, []); - const cleaned = primaryAnswerDisplayText(text); + const cleaned = primaryAnswerDisplayText(text, { preformatted, preserveBold: true }); if (!cleaned) return null; const capsuleDisplay = sourceCapsuleDisplay({ sourceCount }); const previewSources = capsulePreviewSources(bestSource, sources, sourceLinks); diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index ee4d865ff..790e56cba 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -41,7 +41,6 @@ import { type AnswerEvidenceMapRow, type AnswerViewMode } from "@/lib/ward-outpu function StagedAnswerResultSurfaceImpl({ answer, query, - safeAnswerText, bestSource, sourceGovernanceWarnings, sourceSummary, @@ -68,7 +67,6 @@ function StagedAnswerResultSurfaceImpl({ }: { answer: RagAnswer; query: string; - safeAnswerText: string; bestSource: BestSourceRecommendation | null; sourceGovernanceWarnings: SourceGovernanceWarning[]; sourceSummary?: EvidenceSummary; @@ -211,7 +209,8 @@ function StagedAnswerResultSurfaceImpl({ >
can render it. + preserveBold?: boolean; }; export function normalizeDisplayText(value: string) { @@ -202,7 +209,13 @@ export function compactTableFact(fact: NonNullable[ } export function sanitizeAnswerDisplayText(value: string, options: DisplayTextSanitizeOptions = {}) { - const normalized = polishClinicalAnswerProse(sourceTextForClinicalProsePreservingBreaks(value)).trim(); + const normalized = ( + options.preformatted + ? normalizePreformattedDisplayText(value) + : polishClinicalAnswerProse(sourceTextForClinicalProsePreservingBreaks(value), { + preserveBold: options.preserveBold, + }) + ).trim(); if (!normalized) return ""; const artifactStart = normalizeDisplayText(normalized).search( /\{\s*"(?:answer|heading|body|grounded|confidence|citations?|answerSections?|citation_chunk_ids|source_chunk_ids|chunk_id|conflictsOrGaps|quoteCards?)\s*:/i, diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index ab5737dc9..1568806a8 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -572,7 +572,9 @@ function clinicalNotesAvailableTabs(sections: ClinicalDetailSection[]) { function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: AnswerViewMode) { const sections = viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer); - const primaryAnswer = plainAnswerText(answer.answer); + const primaryAnswer = plainAnswerText(answer.answer, { + preformatted: Boolean(answer.preformatted && answer.grounded), + }); const keepVerifySource = answer.answerQualityTier === "source_only" || answer.grounded === false; return sortClinicalDetailSections( sections diff --git a/src/components/clinical-dashboard/output-panel.tsx b/src/components/clinical-dashboard/output-panel.tsx index 36e66524a..4b876fb65 100644 --- a/src/components/clinical-dashboard/output-panel.tsx +++ b/src/components/clinical-dashboard/output-panel.tsx @@ -46,7 +46,9 @@ export function ClinicalOutputPanel({ const rows = evidenceMapRows ?? buildAnswerEvidenceMap(answer); if (sections.length === 0 && (viewMode !== "evidence_map" || rows.length === 0)) return null; const leadSection = sections.find((section) => section.id === "bottom-line") ?? sections[0]; - const primaryAnswer = plainAnswerText(answer.answer); + const primaryAnswer = plainAnswerText(answer.answer, { + preformatted: Boolean(answer.preformatted && answer.grounded), + }); const detailSections = sections .filter((section) => section.id !== "verify-source") .filter((section) => (showLead ? section.id !== leadSection?.id : section.id !== "bottom-line")) diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index ed8e43576..29f6493dc 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -575,6 +575,10 @@ export function buildAnswerRenderModel( }); const allowedBlocks = blockOrder.filter((block) => decisions[block].shown); const answerText = answer.answer.trim(); + // The copy/paste draft is plain text for clinical notes — strip the server's + // high-yield bold markers so a pasted draft never contains literal "**". + // (Preformatted answers carry no bold, so this is a no-op for them.) + const copyAnswerText = answerText.replace(/\*\*/g, ""); return { answerText, @@ -588,7 +592,7 @@ export function buildAnswerRenderModel( relatedDocuments, bestSource, warnings, - copyText: formatAnswerRenderCopyText({ answerText, trust, primarySources, warnings }), + copyText: formatAnswerRenderCopyText({ answerText: copyAnswerText, trust, primarySources, warnings }), debugReasons: options.includeDebugReasons ? decisions : undefined, }; } diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts index 2898ae3f7..80790a050 100644 --- a/src/lib/rag-answer-text.ts +++ b/src/lib/rag-answer-text.ts @@ -204,10 +204,18 @@ function separateSettingRunOns(value: string): string { .replace(/\bfor community patients,?\s+for inpatients,?/gi, "for community patients. For inpatients,"); } -export function polishClinicalAnswerProse(value: string) { - // Bold markers come off before bullet normalization so an emphasized item - // ("o **Reduce dose**") still reads as a sub-bullet to the "o" matcher. - const cleaned = normalizeInlineBulletGlyphs(normalizeSectionText(value).replace(/\*\*([^*]+)\*\*/g, "$1")) +export function polishClinicalAnswerProse(value: string, options: { preserveBold?: boolean } = {}) { + // Bold markers normally come off before bullet normalization so an emphasized + // item ("o **Reduce dose**") still reads as a sub-bullet to the "o" matcher. + // The display path passes preserveBold so can render the + // server's high-yield emphasis (and the un-bold unverified-number safety + // signal); the sub-bullet matcher tolerates a leading "**" so bolded + // sub-bullets still normalize. The server answer-gen path keeps the default + // (strip), so its quality gates are unchanged. + const normalized = normalizeSectionText(value); + const cleaned = normalizeInlineBulletGlyphs( + options.preserveBold ? normalized : normalized.replace(/\*\*([^*]+)\*\*/g, "$1"), + ) .replace(productCatalogueFragmentPattern, " ") .replace(brandOrFormularyFragmentPattern, " ") .replace(imprestLocationPattern, " ") diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 043d81e7d..b7e358b24 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1353,6 +1353,10 @@ async function searchTextChunkCandidates(args: { document_filters: args.documentIds ?? undefined, owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); + // Report the error before returning empty so a schema drift on this + // most-terminal lexical layer surfaces in hybrid_rpc_errors telemetry + // instead of silently degrading to zero candidates. Return value unchanged. + if (error) recordHybridRpcError(args.telemetry, "match_document_chunks_text", error); return error || !data?.length ? ([] as SearchResult[]) : (data as SearchResult[]); }; @@ -1625,6 +1629,7 @@ async function searchDocumentLookupFastPath(args: { ownerId?: string; documentIds?: string[]; matchCount: number; + telemetry?: SearchTelemetry; }): Promise { if (!args.ownerId) return [] as SearchResult[]; const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( @@ -1638,6 +1643,7 @@ async function searchDocumentLookupFastPath(args: { match_count: index === 0 ? 12 : 8, owner_filter: requireOwnerScope(args.ownerId), }); + if (error) recordHybridRpcError(args.telemetry, "match_documents_for_query", error); if (error || !data?.length) return [] as DocumentLookupRow[]; return data as DocumentLookupRow[]; }), @@ -1922,6 +1928,7 @@ async function searchTableFactCandidates(args: { documentIds?: string[]; allowGlobalSearch?: boolean; matchCount: number; + telemetry?: SearchTelemetry; }) { const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( 0, @@ -1935,6 +1942,7 @@ async function searchTableFactCandidates(args: { document_filters: args.documentIds ?? undefined, owner_filter: ownerScopeForDocumentFilteredRetrieval(args.ownerId, args.documentIds, args.allowGlobalSearch), }); + if (error) recordHybridRpcError(args.telemetry, "match_document_table_facts_text", error); if (error || !data?.length) return [] as TableFactRpcRow[]; return data as TableFactRpcRow[]; }), @@ -3133,6 +3141,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), + telemetry, }); const tableFactLatencyMs = Date.now() - tableFactStartedAt; telemetry.supabase_rpc_latency_ms += tableFactLatencyMs; @@ -3154,6 +3163,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { ownerId: args.ownerId, documentIds: documentFilterList, matchCount: candidateCount, + telemetry, }); const documentLookupLatencyMs = Date.now() - documentLookupStartedAt; telemetry.supabase_rpc_latency_ms += documentLookupLatencyMs; diff --git a/src/lib/source-text-sanitizer.ts b/src/lib/source-text-sanitizer.ts index efa3866ae..5488ab2bd 100644 --- a/src/lib/source-text-sanitizer.ts +++ b/src/lib/source-text-sanitizer.ts @@ -292,6 +292,17 @@ export function sourceTextForClinicalProsePreservingBreaks(text: string) { return readableWhitespace(stripLowYieldSourceNoise(sourceTextForDisplayPreservingBreaks(text))); } +// Lossless display normalization for server-`preformatted` answers (document +// support lists, table/visual source references). These are well-formed by +// construction and their "source-noise"-looking tokens — facility codes like +// "MP-0123", all-caps guideline titles — ARE the payload, so the noise-stripping +// prose sanitizer must NOT run on them (it would delete the document names and +// leave garble). Apply only glyph repair + whitespace collapse, matching the +// server's own `finalizeRagAnswerQuality` exemption for `preformatted && grounded`. +export function normalizePreformattedDisplayText(text: string) { + return readableWhitespace(text); +} + export function isLowYieldClinicalText(text: string) { const cleaned = sourceTextForClinicalProse(text); if (!cleaned) return true; @@ -554,7 +565,10 @@ const inlineBulletGlyphPattern = /\s*[•◦▪‣●]+\s*/g; // "Dose:\no Start 750 mg"), hence the start/newline alternatives. Lowercase // followers ("o pregnancy") are deliberately NOT converted: the risk of // deleting a genuine clinical "o" value outweighs the cosmetic artifact. -const subBulletOGlyphPattern = /(?<=^|[\r\n]|[^\d\s]\s)o(?=\s+(?:\d|[A-Z][a-z0-9]|[A-Z]{2,}))/g; +// The optional "**" before the follower lets a bolded sub-bullet ("o **Reduce +// dose**") still be recognised when the display path preserves bold markers; a +// superset of the previous match, so non-bold behaviour is unchanged. +const subBulletOGlyphPattern = /(?<=^|[\r\n]|[^\d\s]\s)o(?=\s+(?:\*\*)?(?:\d|[A-Z][a-z0-9]|[A-Z]{2,}))/g; // Blood-group exemptions: "blood group o RhD negative" / "Blood Type: o // Negative" (any casing, optional colon), or a bare "group/type o" directly // followed by an Rh/positive/negative value, keep their lowercase "o" — it diff --git a/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql new file mode 100644 index 000000000..e40ee538b --- /dev/null +++ b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql @@ -0,0 +1,107 @@ +-- Forward-codify the live retrieval RPC bodies so committed migrations reproduce +-- production. Drift backlog item from 20260705210000_retrieval_owner_filter_sentinel.sql. +-- +-- VERIFIED LIVE STATE (project sjrfecxgysukkwxsowpy "Clinical KB Database", +-- read-only pg_get_functiondef inspection, 2026-07-12) +-- ------------------------------------------------------------------------------ +-- ALL EIGHT primary retrieval RPCs on live ALREADY gate ownership with the +-- fail-closed + sentinel-aware helper retrieval_owner_matches(owner_filter, +-- d.owner_id) — none carry the legacy fail-open inline predicate: +-- match_document_chunks_hybrid(vector,text,integer,double precision,uuid[],uuid) +-- match_document_chunks_text(text,integer,uuid[],uuid) +-- match_document_lookup_chunks_text(text,uuid[],integer,uuid) +-- match_document_table_facts_text(text,integer,uuid[],uuid) +-- match_document_embedding_fields_hybrid(vector,text,integer,double precision,uuid[],uuid) +-- match_document_index_units_hybrid(vector,text,integer,double precision,uuid[],uuid) +-- match_documents_for_query(text,integer,uuid) +-- match_document_chunks(vector,integer,double precision,uuid,uuid) +-- So there is NO live tenancy hole. The gap is reproducibility only: the +-- COMMITTED migration bodies are stale (fail-open + sentinel-blind), so any +-- environment rebuilt from source — Supabase preview branch, `db reset`, DR +-- restore, a new region, or the golden retrieval eval against a fresh DB — gets +-- empty public retrieval plus a fail-open NULL branch that live does not have. +-- +-- TWO NON-PRIMARY functions still carry a fail-open-shaped inline predicate on +-- live (assess separately — NOT part of the primary-retrieval codification): +-- * match_document_embedding_fields_text(text,integer,double precision,uuid[],uuid) +-- — sole gate is `(owner_filter is null or d.owner_id = owner_filter)`: +-- genuine latent fail-open on NULL + sentinel-blind. Confirm whether the +-- app calls this text-only variant (the app uses the _hybrid variant); +-- if live, route it through retrieval_owner_matches too. +-- * get_related_document_metadata(uuid[],uuid) +-- — the primary `documents d` join is fail-closed via +-- retrieval_owner_matches, but the label (l) and summary (s) joins keep +-- `(owner_filter is null or …)`. Backstopped by the fail-closed d gate +-- (NULL → no d rows → no l/s rows), so not a live escape, but tidy up +-- for consistency. +-- +-- WHY THIS FILE IS NOT HAND-FILLED WITH THE RPC BODIES +-- ------------------------------------------------------------------------------ +-- The live bodies total ~26 KB across 8 functions and have diverged FORWARD from +-- committed source (hnsw.ef_search wrapper on match_document_chunks; richer +-- multi-strategy _text/_table_facts_text bodies; left-join quality_score on the +-- hybrid path — see 20260705210000). They must be codified BYTE-FOR-BYTE from +-- live: a transcription error or a stale-source rebuild would REGRESS retrieval, +-- the exact trap 20260705210000 was neutralized to prevent. That requires a +-- direct DB dump (the environment running this review has no Postgres connection +-- string — only PostgREST keys — so it cannot produce a byte-perfect dump). This +-- is owner-driven, matching the documented `supabase migration repair` drift +-- workflow. +-- +-- OWNER COMPLETION (byte-perfect, no hand transcription; each step confirmed): +-- ------------------------------------------------------------------------------ +-- 1. With the linked project + DB connection string ($SUPABASE_DB_URL), append +-- the EXACT live bodies to this file: +-- +-- psql "$SUPABASE_DB_URL" -X -q -At -o /tmp/rpc_bodies.sql -c " +-- select string_agg(pg_get_functiondef(p.oid) || E';\n', E'\n') +-- from pg_proc p join pg_namespace n on n.oid = p.pronamespace +-- where n.nspname = 'public' and p.proname in ( +-- 'match_document_chunks_hybrid','match_document_chunks_text', +-- 'match_document_lookup_chunks_text','match_document_table_facts_text', +-- 'match_document_embedding_fields_hybrid','match_document_index_units_hybrid', +-- 'match_documents_for_query','match_document_chunks') +-- order by p.proname;" +-- cat /tmp/rpc_bodies.sql >> supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql +-- +-- (`create or replace function` is idempotent; applying on live is a no-op +-- because the bodies already match. On fresh rebuilds it installs them.) +-- 2. Apply on a Supabase PREVIEW BRANCH. The DO $verify$ guard below must pass, +-- then run: npm run eval:retrieval:quality -- --fail-on-threshold (36/36) +-- to prove the rebuilt retrieval matches live before this reaches main. +-- 3. Only after a green preview + golden eval, land + apply. + +set search_path = public, extensions, pg_temp; + +-- Safe, idempotent guard: re-assert the fail-closed + sentinel-aware truth table +-- for retrieval_owner_matches so a bad redefinition (or an environment where the +-- helper drifted back to fail-open) fails this migration loudly instead of +-- silently shipping a tenancy regression. Mirrors 20260708160001. +do $verify$ +declare + a uuid := '11111111-1111-1111-1111-111111111111'; + b uuid := '22222222-2222-2222-2222-222222222222'; + sentinel uuid := '00000000-0000-0000-0000-000000000000'; +begin + if public.retrieval_owner_matches(null, a) is not false then + raise exception 'retrieval_owner_matches(NULL, uuid) must be FALSE (fail-closed)'; + end if; + if public.retrieval_owner_matches(null, null) is not false then + raise exception 'retrieval_owner_matches(NULL, NULL) must be FALSE (fail-closed)'; + end if; + if public.retrieval_owner_matches(sentinel, null) is not true then + raise exception 'retrieval_owner_matches(sentinel, NULL) must be TRUE (public row)'; + end if; + if public.retrieval_owner_matches(sentinel, a) is not false then + raise exception 'retrieval_owner_matches(sentinel, owned) must be FALSE (public-only)'; + end if; + if public.retrieval_owner_matches(a, a) is not true then + raise exception 'retrieval_owner_matches(owner, same owner) must be TRUE'; + end if; + if public.retrieval_owner_matches(a, b) is not false then + raise exception 'retrieval_owner_matches(owner, other owner) must be FALSE'; + end if; +end +$verify$; + +-- >>> OWNER: append the byte-perfect live RPC bodies below this line (step 1). <<< diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts index 5cf6f2a00..cb36d2766 100644 --- a/tests/answer-render-policy.test.ts +++ b/tests/answer-render-policy.test.ts @@ -125,6 +125,18 @@ function answer(overrides: Partial = {}): RagAnswer { } describe("answer render policy", () => { + it("strips high-yield bold markers from the copy/paste clinical draft", () => { + const model = buildAnswerRenderModel( + answer({ + answer: "For red-range results, **withhold clozapine** and recheck ANC **within 24 hours**.", + }), + ); + // The pasted draft is plain text for clinical notes — no literal "**". + expect(model.copyText).not.toContain("**"); + expect(model.copyText).toContain("withhold clozapine"); + expect(model.copyText).toContain("within 24 hours"); + }); + it("limits unsupported answers to source review and warnings even when raw extras are present", () => { const model = buildAnswerRenderModel( answer({ diff --git a/tests/display-text.test.ts b/tests/display-text.test.ts index f5119d299..174a22b16 100644 --- a/tests/display-text.test.ts +++ b/tests/display-text.test.ts @@ -7,6 +7,31 @@ import { } from "../src/components/clinical-dashboard/display-text"; describe("clinical dashboard display text", () => { + it("preserves document names and facility codes for preformatted answers", () => { + // A server-`preformatted` document-support-list answer: its all-caps titles + // and facility codes ARE the payload and must survive display. + const docList = + "I found 2 indexed documents that support this query: LITHIUM CLOZAPINE CO-PRESCRIBING GUIDELINE MP-0123; and Acute Behavioural Disturbance Protocol (NOCC)."; + + const rendered = sanitizeAnswerDisplayText(docList, { preformatted: true }); + expect(rendered).toContain("LITHIUM CLOZAPINE CO-PRESCRIBING GUIDELINE"); + expect(rendered).toContain("MP-0123"); + expect(rendered).toContain("(NOCC)"); + + // Without the flag the prose sanitizer deletes exactly those tokens — this is + // the mangling the preformatted path exists to prevent. + const mangled = sanitizeAnswerDisplayText(docList); + expect(mangled).not.toContain("LITHIUM CLOZAPINE CO-PRESCRIBING GUIDELINE"); + }); + + it("keeps high-yield bold when preserveBold is set, strips it otherwise", () => { + const bolded = "Withhold clozapine if ANC is **below 1.5**."; + expect(sanitizeAnswerDisplayText(bolded, { preserveBold: true })).toContain("**below 1.5**"); + const stripped = sanitizeAnswerDisplayText(bolded); + expect(stripped).not.toContain("**"); + expect(stripped).toContain("below 1.5"); + }); + it("polishes cached generated answer prose before rendering", () => { const noisy = "Lithium Carbonate 250 mg Tablet – Lithicarb®. Imprest location: Formulary One DOSAGE & DOSAGE ADJUSTMENTS Therapy with lithium should always begin with conventional tablets (Lithium Carbonate 250 mg) to stabilise the do. Lithium MONITORING Baseline Tests1."; diff --git a/tests/rag-answer-text.test.ts b/tests/rag-answer-text.test.ts index 481d3c877..8e418b8cc 100644 --- a/tests/rag-answer-text.test.ts +++ b/tests/rag-answer-text.test.ts @@ -9,6 +9,20 @@ import { } from "../src/lib/rag-answer-text"; describe("RAG answer text helpers", () => { + it("strips bold by default but preserves it under preserveBold", () => { + const input = "Escalate if **ANC below 1.5**."; + expect(polishClinicalAnswerProse(input)).not.toContain("**"); + expect(polishClinicalAnswerProse(input, { preserveBold: true })).toContain("**ANC below 1.5**"); + }); + + it("normalizes a bolded sub-bullet while preserving the bold", () => { + const out = polishClinicalAnswerProse("Actions: o **Reduce dose** o **Recheck ANC**", { preserveBold: true }); + expect(out).toContain("**Reduce dose**"); + expect(out).toContain("**Recheck ANC**"); + // The leading "o" sub-bullet glyph is normalized away, not left as a literal. + expect(out).not.toMatch(/\bo\s+\*\*Reduce/); + }); + it("normalizes balanced word tokens for query/source matching", () => { expect(splitBalancedWords("Clozapine: red-range blood results!")).toEqual([ "clozapine", diff --git a/tests/rag-offline-answer.test.ts b/tests/rag-offline-answer.test.ts index be322688a..f6a817215 100644 --- a/tests/rag-offline-answer.test.ts +++ b/tests/rag-offline-answer.test.ts @@ -76,16 +76,26 @@ class EmptyQuery implements PromiseLike<{ data: unknown[]; error: null }> { } } -async function answerOffline(query: string, textSources: SearchResult[]) { +type RpcResult = { data: unknown; error: unknown }; + +async function answerOffline( + query: string, + textSources: SearchResult[], + rpcHandler?: (name: string) => RpcResult | Promise, +) { // offline provider mode forces source-only behaviour regardless of key presence. vi.stubEnv("RAG_PROVIDER_MODE", "offline"); vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0"); vi.stubEnv("RAG_ANSWER_CACHE_TTL_MS", "0"); - const rpc = vi.fn(async (name: string) => { - if (name === "match_document_chunks_text") return { data: textSources, error: null }; - return { data: [], error: null }; - }); + const rpc = vi.fn( + rpcHandler + ? async (name: string) => rpcHandler(name) + : async (name: string) => { + if (name === "match_document_chunks_text") return { data: textSources, error: null }; + return { data: [], error: null }; + }, + ); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ rpc, from: vi.fn(() => new EmptyQuery()) }), })); @@ -152,6 +162,27 @@ describe("source-only / offline answers", () => { expect(answer.fallbackReason).toContain("source_only"); }); + it("stays fail-closed (does not throw) when a lexical retrieval RPC errors", async () => { + // F8: the terminal lexical layer now records the RPC error in retrieval + // telemetry via recordHybridRpcError before returning empty. The return + // value is unchanged, so the path must still degrade to a source-gap answer + // rather than throw. + const rpcError = { + code: "42P01", + message: "relation match_document_chunks_text does not exist", + hint: null, + }; + const { answer, generateStructuredTextResult } = await answerOffline( + "What ANC threshold should withhold clozapine?", + [], + (name) => (name === "match_document_chunks_text" ? { data: null, error: rpcError } : { data: [], error: null }), + ); + + expect(generateStructuredTextResult).not.toHaveBeenCalled(); + expect(answer.grounded).toBe(false); + expect(answer.confidence).toBe("unsupported"); + }); + it("fails closed to a source-gap answer when there is no usable evidence", async () => { const { answer, generateStructuredTextResult } = await answerOffline( "What is the duress response procedure for the community team?", diff --git a/tests/source-text-sanitizer.test.ts b/tests/source-text-sanitizer.test.ts index 8bd43b99d..4711fa365 100644 --- a/tests/source-text-sanitizer.test.ts +++ b/tests/source-text-sanitizer.test.ts @@ -6,6 +6,7 @@ import { lowYieldSourceNoiseScore, normalizeExtractedGlyphs, normalizeInlineBulletGlyphs, + normalizePreformattedDisplayText, polishStoredSynopsis, repairTruncatedCompactTail, fenceSourceEvidence, @@ -20,6 +21,19 @@ import { } from "../src/lib/source-text-sanitizer"; describe("source text sanitizer", () => { + it("normalizePreformattedDisplayText is lossless for document names and codes", () => { + // Preformatted answers rely on this: it must repair glyphs/whitespace but + // NOT strip facility codes, all-caps titles, or parenthetical codes. + const docList = + "I found 2 indexed documents that support this query: LITHIUM CLOZAPINE GUIDELINE MP-0123;\n\nand Behaviour Protocol (NOCC)."; + const out = normalizePreformattedDisplayText(docList); + expect(out).toContain("LITHIUM CLOZAPINE GUIDELINE"); + expect(out).toContain("MP-0123"); + expect(out).toContain("(NOCC)"); + // Collapses runs of spaces but keeps the content intact. + expect(out).not.toMatch(/ {2,}/); + }); + it("removes complete and partial internal image metadata from display text", () => { const text = "Source mentions: [[IMAGE_DATA_START]] Image ID: img-1; Source kind: table_crop; Image type: clinical_table; Table text: | Dose | Route | [[IMAGE_DATA_END]] Continue oral medication when indicated."; From d5ab4c38533d94613e527d7ef7cf388c00c14cd0 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:05:55 +0000 Subject: [PATCH 02/11] fix: apply CodeRabbit auto-fixes Fixed 5 file(s) based on 4 unresolved review comments. Co-authored-by: CodeRabbit --- .../clinical-dashboard/display-text.ts | 4 ++- src/lib/source-text-sanitizer.ts | 20 +++++++++----- ...forward_codify_retrieval_owner_matches.sql | 5 ++-- tests/display-text.test.ts | 25 ++++++++++++++++++ tests/source-text-sanitizer.test.ts | 26 +++++++++++++++++++ 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/components/clinical-dashboard/display-text.ts b/src/components/clinical-dashboard/display-text.ts index beca89bd9..d21e4fb5b 100644 --- a/src/components/clinical-dashboard/display-text.ts +++ b/src/components/clinical-dashboard/display-text.ts @@ -211,7 +211,9 @@ export function compactTableFact(fact: NonNullable[ export function sanitizeAnswerDisplayText(value: string, options: DisplayTextSanitizeOptions = {}) { const normalized = ( options.preformatted - ? normalizePreformattedDisplayText(value) + ? normalizePreformattedDisplayText(value, { + preserveBold: options.preserveBold, + }) : polishClinicalAnswerProse(sourceTextForClinicalProsePreservingBreaks(value), { preserveBold: options.preserveBold, }) diff --git a/src/lib/source-text-sanitizer.ts b/src/lib/source-text-sanitizer.ts index 5488ab2bd..d6a71c66d 100644 --- a/src/lib/source-text-sanitizer.ts +++ b/src/lib/source-text-sanitizer.ts @@ -299,8 +299,11 @@ export function sourceTextForClinicalProsePreservingBreaks(text: string) { // prose sanitizer must NOT run on them (it would delete the document names and // leave garble). Apply only glyph repair + whitespace collapse, matching the // server's own `finalizeRagAnswerQuality` exemption for `preformatted && grounded`. -export function normalizePreformattedDisplayText(text: string) { - return readableWhitespace(text); +// When preserveBold is false, strip **bold** markers so literal Markdown markers +// never leak through to the display. +export function normalizePreformattedDisplayText(text: string, options: { preserveBold?: boolean } = {}) { + const normalized = readableWhitespace(text); + return options.preserveBold ? normalized : normalized.replace(/\*\*([^*]+)\*\*/g, "$1"); } export function isLowYieldClinicalText(text: string) { @@ -580,14 +583,17 @@ const groupTypeLabelTailPattern = /\b(?:group|type):?\s$/i; // A word qualifying group/type ("risk group:", "test type:") marks a list // label, not a blood label — its positive/negative items are list content. const qualifiedGroupTypeTailPattern = /\b[A-Za-z][\w-]*\s+(?:group|type):?\s$/i; -const rhValueHeadPattern = /^\s+rh(?:d)?\b/i; -const posNegValueHeadPattern = /^\s+(?:pos(?:itive)?|neg(?:ative)?)\b/i; +const rhValueHeadPattern = /^\s+(?:\*\*)?rh(?:d)?\b/i; +const posNegValueHeadPattern = /^\s+(?:\*\*)?(?:pos(?:itive)?|neg(?:ative)?)\b/i; // An entire line that is just the ABO value ("o RhD negative", "o Negative", -// "o Rh positive") — the strongest signal that the "o" is the group itself. +// "o Rh positive", "o **RhD negative**", "o **Negative**") — the strongest +// signal that the "o" is the group itself. Bold markers around the value are +// allowed so high-yield emphasis from the server never causes a blood value to +// be misclassified as a bullet. const standaloneBloodValueLinePattern = - /^o\s+(?:rh(?:d)?(?:\s+(?:pos(?:itive)?|neg(?:ative)?))?|pos(?:itive)?|neg(?:ative)?)$/i; + /^o\s+(?:\*\*)?(?:rh(?:d)?(?:\s+(?:pos(?:itive)?|neg(?:ative)?))?|pos(?:itive)?|neg(?:ative)?)(?:\*\*)?$/i; const bloodValueWithNounTailLinePattern = - /^o\s+(?:rh(?:d)?\s+)?(?:pos(?:itive)?|neg(?:ative)?)\s+(?:blood(?!\s+(?:cultures?|tests?|screens?|samples?|results?)\b)|red\s+cells?)\b/i; + /^o\s+(?:\*\*)?(?:rh(?:d)?\s+)?(?:pos(?:itive)?|neg(?:ative)?)(?:\*\*)?\s+(?:blood(?!\s+(?:cultures?|tests?|screens?|samples?|results?)\b)|red\s+cells?)\b/i; function replaceSubBulletOGlyphs(text: string, joiner: string) { return text.replace(subBulletOGlyphPattern, (match, offset: number) => { diff --git a/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql index e40ee538b..00dff3de7 100644 --- a/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql +++ b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql @@ -54,14 +54,13 @@ -- the EXACT live bodies to this file: -- -- psql "$SUPABASE_DB_URL" -X -q -At -o /tmp/rpc_bodies.sql -c " --- select string_agg(pg_get_functiondef(p.oid) || E';\n', E'\n') +-- select string_agg(pg_get_functiondef(p.oid) || E';\n', E'\n' order by p.proname, p.oid) -- from pg_proc p join pg_namespace n on n.oid = p.pronamespace -- where n.nspname = 'public' and p.proname in ( -- 'match_document_chunks_hybrid','match_document_chunks_text', -- 'match_document_lookup_chunks_text','match_document_table_facts_text', -- 'match_document_embedding_fields_hybrid','match_document_index_units_hybrid', --- 'match_documents_for_query','match_document_chunks') --- order by p.proname;" +-- 'match_documents_for_query','match_document_chunks');" -- cat /tmp/rpc_bodies.sql >> supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql -- -- (`create or replace function` is idempotent; applying on live is a no-op diff --git a/tests/display-text.test.ts b/tests/display-text.test.ts index 174a22b16..be5e5567f 100644 --- a/tests/display-text.test.ts +++ b/tests/display-text.test.ts @@ -32,6 +32,31 @@ describe("clinical dashboard display text", () => { expect(stripped).toContain("below 1.5"); }); + it("handles bold markers correctly in preformatted answers", () => { + const preformattedWithBold = + "The following documents are relevant: **LITHIUM THERAPY GUIDELINE MP-0123**; Clozapine Monitoring Protocol."; + + // When preserveBold is true, keep the markers for SafeBoldText rendering. + const withBold = sanitizeAnswerDisplayText(preformattedWithBold, { + preformatted: true, + preserveBold: true, + }); + expect(withBold).toContain("**LITHIUM THERAPY GUIDELINE MP-0123**"); + + // When preserveBold is false or omitted, strip the markers so literal Markdown + // markers never leak through when PriorAnswerTurnSurface renders collapsed text. + const withoutBold = sanitizeAnswerDisplayText(preformattedWithBold, { + preformatted: true, + preserveBold: false, + }); + expect(withoutBold).not.toContain("**"); + expect(withoutBold).toContain("LITHIUM THERAPY GUIDELINE MP-0123"); + + const defaultBehavior = sanitizeAnswerDisplayText(preformattedWithBold, { preformatted: true }); + expect(defaultBehavior).not.toContain("**"); + expect(defaultBehavior).toContain("LITHIUM THERAPY GUIDELINE MP-0123"); + }); + it("polishes cached generated answer prose before rendering", () => { const noisy = "Lithium Carbonate 250 mg Tablet – Lithicarb®. Imprest location: Formulary One DOSAGE & DOSAGE ADJUSTMENTS Therapy with lithium should always begin with conventional tablets (Lithium Carbonate 250 mg) to stabilise the do. Lithium MONITORING Baseline Tests1."; diff --git a/tests/source-text-sanitizer.test.ts b/tests/source-text-sanitizer.test.ts index 4711fa365..d7c4f81d9 100644 --- a/tests/source-text-sanitizer.test.ts +++ b/tests/source-text-sanitizer.test.ts @@ -522,6 +522,32 @@ describe("normalizeInlineBulletGlyphs", () => { expect(normalizeInlineBulletGlyphs("donor group o RhD negative")).toBe("donor group o RhD negative"); }); + it("keeps blood-group values with bold markers intact", () => { + // Line-start forms: "o **RhD negative**" is a blood value, not a bullet. + expect(normalizeInlineBulletGlyphs("o **RhD negative**")).toBe("o **RhD negative**"); + expect(normalizeInlineBulletGlyphs("o **Negative**")).toBe("o **Negative**"); + expect(normalizeInlineBulletGlyphs("o **Rh positive**")).toBe("o **Rh positive**"); + + // Label-prefixed forms: "group/type: o **Negative**" is a blood value. + expect(normalizeInlineBulletGlyphs("blood group: o **RhD negative**")).toBe("blood group: o **RhD negative**"); + expect(normalizeInlineBulletGlyphs("Blood Type: o **Negative**")).toBe("Blood Type: o **Negative**"); + expect(normalizeInlineBulletGlyphs("group o **Negative** units available")).toBe( + "group o **Negative** units available", + ); + + // Blood-value + noun forms with bold markers. + expect(normalizeInlineBulletGlyphs("o **Negative** blood should be used")).toBe("o **Negative** blood should be used"); + expect(normalizeInlineBulletGlyphs("o **RhD negative** red cells are required")).toBe( + "o **RhD negative** red cells are required", + ); + + // Non-blood bullets with bold content should still convert. + expect(normalizeInlineBulletGlyphs("o **Reduce dose** if ANC falls")).toBe("**Reduce dose** if ANC falls"); + expect(normalizeInlineBulletGlyphs("Risk: o **Positive symptoms** require urgent review")).toBe( + "Risk: **Positive symptoms** require urgent review", + ); + }); + it("repairs a label colon followed by a converted sub-bullet ('Label:; item' → 'Label: item')", () => { expect(normalizeInlineBulletGlyphs("Acute Mania: o IR product: 750 to 1000mg daily")).toBe( "Acute Mania: IR product: 750 to 1000mg daily", From 65e5575c4f6ff364b5bc52f7698de7d75986e871 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:29:41 +0000 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`c?= =?UTF-8?q?laude/rag-review-improvements-f1bf84`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @BigSimmo. * https://github.com/BigSimmo/Database/pull/514#issuecomment-4951731143 The following files were modified: * `src/components/ClinicalDashboard.tsx` * `src/components/clinical-dashboard/answer-content.tsx` * `src/components/clinical-dashboard/answer-result-surface.tsx` * `src/components/clinical-dashboard/display-text.ts` * `src/components/clinical-dashboard/evidence-panels.tsx` * `src/components/clinical-dashboard/output-panel.tsx` * `src/lib/answer-render-policy.ts` * `src/lib/rag-answer-text.ts` * `src/lib/rag.ts` * `src/lib/source-text-sanitizer.ts` --- src/components/ClinicalDashboard.tsx | 18 ++++++++-- .../clinical-dashboard/answer-content.tsx | 34 +++++++++++++++++++ .../answer-result-surface.tsx | 7 +++- .../clinical-dashboard/display-text.ts | 18 ++++++++++ .../clinical-dashboard/evidence-panels.tsx | 7 ++++ .../clinical-dashboard/output-panel.tsx | 10 ++++++ src/lib/answer-render-policy.ts | 7 ++++ src/lib/rag-answer-text.ts | 15 +++++++- src/lib/rag.ts | 30 +++++++++++++--- src/lib/source-text-sanitizer.ts | 25 +++++++++++++- 10 files changed, 161 insertions(+), 10 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index fa47b66bc..6d884ce42 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -498,9 +498,13 @@ function answerTimedOutError() { } /** - * Read-only surface for a previous turn in the answer thread. Renders the - * question bubble and the natural-language answer with its source capsule; - * evidence drawers, clinical notes, and feedback stay on the latest turn only. + * Renders a collapsible, read-only view of a previous answer-thread turn with its question, answer, sources, and source-review notice. + * + * @param turn - The previous question and answer turn to display + * @param copied - Whether the turn's answer has been copied + * @param collapsed - Whether the answer content is collapsed + * @param onToggleCollapsed - Called when the answer visibility is toggled + * @param onCopy - Called with the answer text when copying is requested */ function PriorAnswerTurnSurface({ turn, @@ -678,6 +682,14 @@ function mergeDocumentRefresh(current: ClinicalDocument[], updates: ClinicalDocu }); } +/** + * Renders the clinical search dashboard, including document search, answer generation, conversation history, source management, and ingestion controls. + * + * @param initialSearchMode - The mode selected when the dashboard loads. + * @param initialQuery - The initial search or composer query. + * @param focusSearch - Whether to focus the search input on load. + * @param autoRunSearch - Whether to automatically submit the initial query. + */ export function ClinicalDashboard({ initialSearchMode = "answer", initialQuery = "", diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 8b7edc725..d49793ac8 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -172,6 +172,12 @@ export const SourceImage = memo(function SourceImage({ ); }); +/** + * Displays the active search scope and source governance warnings. + * + * @param scope - The current search scope summary, or `null` when unavailable + * @param warnings - Source governance warnings to display + */ export function ScopeAndGovernanceNotice({ scope, warnings, @@ -234,6 +240,13 @@ export type AnswerDisplayTextOptions = { const primaryAnswerSafetySignalPattern = /\b(?:withhold|withheld|stop|cease|hold|threshold|escalat|urgent|red\s*zone|amber|immediately|do not|contraindicat|toxic)\b/i; +/** + * Produces sanitized, display-ready text for an answer. + * + * @param value - The answer text to sanitize + * @param options - Controls preformatted handling and bold-text preservation + * @returns The sanitized answer text + */ export function plainAnswerText(value: string, options: AnswerDisplayTextOptions = {}) { // clinicalProseUsefulness runs the source-noise stripper, so preformatted // answers bypass it and go straight to the lossless display path. @@ -248,6 +261,13 @@ export function plainAnswerText(value: string, options: AnswerDisplayTextOptions .trim(); } +/** + * Selects and compacts the primary answer text while preserving safety-critical guidance. + * + * @param value - The answer text to prepare for display + * @param options - Formatting options, including preformatted mode + * @returns The display-ready answer text + */ function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) { const cleaned = plainAnswerText(value, options); // Deterministic preformatted answers are already concise and display-ready; @@ -567,6 +587,20 @@ function SourcePreviewContent({ ); } +/** + * Displays a sanitized clinical answer with source status, source previews, and copy actions. + * + * @param text - The raw answer text to display. + * @param preformatted - Whether to preserve the supplied formatting during display processing. + * @param sourceCount - The number of direct sources associated with the answer. + * @param sourceOnly - Whether to show a notice that the answer was assembled solely from source passages. + * @param bestSource - The highest-priority source recommendation, when available. + * @param sources - Search results used to build the source preview. + * @param sourceLinks - Source links and snippets associated with the answer. + * @param copied - Whether the answer has been copied. + * @param onCopy - Callback invoked to copy the answer with source status. + * @returns The rendered answer section, or `null` when the answer has no displayable text. + */ export function NaturalLanguageAnswer({ text, preformatted = false, diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index 790e56cba..1619c3c19 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import Link from "next/link"; import { memo, type RefObject, useCallback, useEffect, useRef, useState } from "react"; @@ -38,6 +38,11 @@ import type { } from "@/lib/types"; import { type AnswerEvidenceMapRow, type AnswerViewMode } from "@/lib/ward-output"; +/** + * Renders a staged answer with inline content and optional clinical notes, evidence, safety findings, and follow-up interfaces. + * + * @returns The staged answer surface. + */ function StagedAnswerResultSurfaceImpl({ answer, query, diff --git a/src/components/clinical-dashboard/display-text.ts b/src/components/clinical-dashboard/display-text.ts index d21e4fb5b..e752721d5 100644 --- a/src/components/clinical-dashboard/display-text.ts +++ b/src/components/clinical-dashboard/display-text.ts @@ -25,6 +25,12 @@ export type DisplayTextSanitizeOptions = { preserveBold?: boolean; }; +/** + * Normalizes display text by trimming surrounding whitespace and collapsing internal whitespace. + * + * @param value - The display text to normalize + * @returns The normalized display text + */ export function normalizeDisplayText(value: string) { return value.trim().replace(/\s+/g, " "); } @@ -189,6 +195,12 @@ export function compactSourceSnippet(value: string, options: CompactSourceSnippe return text; } +/** + * Compacts table fact fields into a deduplicated display representation. + * + * @param fact - The table fact whose fields should be compacted + * @returns The fact identifier and cleaned fields, or `null` when no displayable fields remain + */ export function compactTableFact(fact: NonNullable[number]) { const fields = [ { label: "Table", value: fact.table_title }, @@ -208,6 +220,12 @@ export function compactTableFact(fact: NonNullable[ return cleanedFields.length ? { id: fact.id, fields: cleanedFields } : null; } +/** + * Sanitizes answer text for display while removing embedded artifacts and low-information content. + * + * @param options - Controls minimum content thresholds, preformatted text handling, and bold formatting preservation. + * @returns The cleaned answer text, or an empty string when the input is empty, invalid, or artifact-like. + */ export function sanitizeAnswerDisplayText(value: string, options: DisplayTextSanitizeOptions = {}) { const normalized = ( options.preformatted diff --git a/src/components/clinical-dashboard/evidence-panels.tsx b/src/components/clinical-dashboard/evidence-panels.tsx index 1568806a8..3b1e98d96 100644 --- a/src/components/clinical-dashboard/evidence-panels.tsx +++ b/src/components/clinical-dashboard/evidence-panels.tsx @@ -569,6 +569,13 @@ function clinicalNotesAvailableTabs(sections: ClinicalDetailSection[]) { .filter((tab) => tab.count > 0); } +/** + * Builds the non-empty clinical detail sections used by the clinical notes view. + * + * @param answer - The answer from which to derive clinical detail sections. + * @param viewMode - Selects the standard or high-yield section set. + * @returns The sorted clinical detail sections with display-ready items. + */ function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: AnswerViewMode) { const sections = viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer); diff --git a/src/components/clinical-dashboard/output-panel.tsx b/src/components/clinical-dashboard/output-panel.tsx index 4b876fb65..12a1d1f07 100644 --- a/src/components/clinical-dashboard/output-panel.tsx +++ b/src/components/clinical-dashboard/output-panel.tsx @@ -26,6 +26,16 @@ import { buildHighYieldClinicalOutputSections, } from "@/lib/ward-output"; +/** + * Renders a source-backed clinical answer with structured details, lead content, or an evidence map. + * + * @param answer - The clinical answer to display + * @param collapsed - Whether to wrap the content in a collapsed utility drawer + * @param showLead - Whether to display the concise lead section + * @param viewMode - The presentation mode for the clinical output + * @param onViewModeChange - Callback invoked when the presentation mode changes + * @param evidenceMapRows - Evidence map rows to display instead of computing them from the answer + */ export function ClinicalOutputPanel({ answer, collapsed = false, diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index 29f6493dc..9f198c588 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -544,6 +544,13 @@ export function formatAnswerRenderCopyText(args: { .trim(); } +/** + * Builds a trust-gated render model for a clinical answer, including sources, evidence, warnings, and copy-ready text. + * + * @param answer - The clinical answer and associated evidence to render + * @param options - Optional source overrides and diagnostic decision details + * @returns The structured answer render model + */ export function buildAnswerRenderModel( answer: RagAnswer, options: BuildAnswerRenderModelOptions = {}, diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts index 80790a050..418954660 100644 --- a/src/lib/rag-answer-text.ts +++ b/src/lib/rag-answer-text.ts @@ -197,13 +197,26 @@ function removeBadAnswerFragments(value: string) { // immediately followed by a community schedule with no sentence break (e.g. "...monitored daily // for inpatients for community patients weekly..."). The synthesis prompt handles most of these, // but this is a narrow deterministic safety-net for the clearest recurring pattern. It also -// consumes the original comma so it cannot produce a double comma. +/** + * Separates flattened inpatient and community patient schedule phrases into distinct sentences. + * + * @param value - Clinical prose containing a setting-related run-on phrase + * @returns The prose with the targeted setting run-on replaced by separate sentences + */ function separateSettingRunOns(value: string): string { return value .replace(/\bfor inpatients,?\s+for community patients,?/gi, "for inpatients. For community patients,") .replace(/\bfor community patients,?\s+for inpatients,?/gi, "for community patients. For inpatients,"); } +/** + * Cleans clinical answer text by removing artifacts, normalizing formatting, and improving readability. + * + * @param value - The clinical answer text to polish + * @param options - Formatting options + * @param options.preserveBold - Whether to preserve inline bold markers + * @returns The polished clinical answer text + */ export function polishClinicalAnswerProse(value: string, options: { preserveBold?: boolean } = {}) { // Bold markers normally come off before bullet normalization so an emphasized // item ("o **Reduce dose**") still reads as a sub-bullet to the "o" matcher. diff --git a/src/lib/rag.ts b/src/lib/rag.ts index b7e358b24..333e4087c 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1336,7 +1336,14 @@ function mergeSearchResults(primary: SearchResult[], secondary: SearchResult[]) return Array.from(merged.values()); } -/** Search text chunk candidates. */ +/** + * Retrieves lexical document chunk candidates for the supplied query variants. + * + * @param queryVariants - Query variants used for strict matching and recall-oriented fallback searches. + * @param matchCount - Maximum number of candidates requested for the primary query. + * @param telemetry - Optional retrieval telemetry updated with RPC failures and text-relaxation usage. + * @returns Matching document chunks, using corrected or relaxed queries when strict matching produces no candidates. + */ async function searchTextChunkCandidates(args: { supabase: ReturnType; queryVariants: string[]; @@ -1621,7 +1628,12 @@ async function fetchDocumentTitleAliasRows(args: { })); } -/** Search document lookup fast path. */ +/** + * Retrieves and ranks document chunks for document-focused queries. + * + * @param args - Search configuration, including the required owner scope and optional document restrictions. + * @returns Ranked document lookup results, limited to `matchCount`. + */ async function searchDocumentLookupFastPath(args: { supabase: ReturnType; query: string; @@ -1919,7 +1931,12 @@ async function loadChunksForSignalMatches(args: { .filter(Boolean) as SearchResult[]; } -/** Search table fact candidates. */ +/** + * Retrieves document chunks containing table facts relevant to a query. + * + * @param args - Retrieval options, including query variants, scope filters, result limit, and optional telemetry. + * @returns Search results containing the highest-ranked table facts grouped by source chunk. + */ async function searchTableFactCandidates(args: { supabase: ReturnType; query: string; @@ -2884,7 +2901,12 @@ function shouldUseMemoryBeforeFastPath(queryClass: RagQueryClass) { return queryClass === "table_threshold" || queryClass === "medication_dose_risk" || queryClass === "comparison"; } -/** Search chunks with telemetry. */ +/** + * Retrieves and ranks document chunks using lexical, structured, memory, and embedding-based evidence, while recording retrieval telemetry. + * + * @param args - Retrieval options, including the query, scope, search mode, and embedding preferences. + * @returns The ranked search results and telemetry describing the retrieval process. + */ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { assertGlobalSearchAllowed(args); throwIfAborted(args.signal); diff --git a/src/lib/source-text-sanitizer.ts b/src/lib/source-text-sanitizer.ts index d6a71c66d..848152681 100644 --- a/src/lib/source-text-sanitizer.ts +++ b/src/lib/source-text-sanitizer.ts @@ -288,6 +288,11 @@ export function sourceTextForClinicalProse(text: string) { return compactWhitespace(stripLowYieldSourceNoise(stripInternalImageDataBlocks(text))); } +/** + * Produces clinical prose with meaningful line breaks preserved. + * + * @returns The cleaned text with normalized whitespace and low-yield source noise removed. + */ export function sourceTextForClinicalProsePreservingBreaks(text: string) { return readableWhitespace(stripLowYieldSourceNoise(sourceTextForDisplayPreservingBreaks(text))); } @@ -300,12 +305,23 @@ export function sourceTextForClinicalProsePreservingBreaks(text: string) { // leave garble). Apply only glyph repair + whitespace collapse, matching the // server's own `finalizeRagAnswerQuality` exemption for `preformatted && grounded`. // When preserveBold is false, strip **bold** markers so literal Markdown markers -// never leak through to the display. +/** + * Normalizes preformatted text for display while preserving its readable structure. + * + * @param options - Controls whether Markdown bold markers are preserved. + * @returns The normalized text, with bold markers removed unless `preserveBold` is `true`. + */ export function normalizePreformattedDisplayText(text: string, options: { preserveBold?: boolean } = {}) { const normalized = readableWhitespace(text); return options.preserveBold ? normalized : normalized.replace(/\*\*([^*]+)\*\*/g, "$1"); } +/** + * Determines whether source text contains little clinically useful content. + * + * @param text - Source text to assess. + * @returns `true` if the text is predominantly source noise or contains only a short remainder, `false` otherwise. + */ export function isLowYieldClinicalText(text: string) { const cleaned = sourceTextForClinicalProse(text); if (!cleaned) return true; @@ -595,6 +611,13 @@ const standaloneBloodValueLinePattern = const bloodValueWithNounTailLinePattern = /^o\s+(?:\*\*)?(?:rh(?:d)?\s+)?(?:pos(?:itive)?|neg(?:ative)?)(?:\*\*)?\s+(?:blood(?!\s+(?:cultures?|tests?|screens?|samples?|results?)\b)|red\s+cells?)\b/i; +/** + * Replaces OCR-style lowercase `o` sub-bullets while preserving blood-group value text. + * + * @param text - Text containing potential OCR sub-bullet markers + * @param joiner - Text used to replace recognized sub-bullet markers + * @returns Text with eligible sub-bullet markers replaced + */ function replaceSubBulletOGlyphs(text: string, joiner: string) { return text.replace(subBulletOGlyphPattern, (match, offset: number) => { const before = text.slice(0, offset); From 71bd4c6cd007178b728aa218c017f0b028b1d3a0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:15:11 +0800 Subject: [PATCH 04/11] fix: preserve safety cues in compact answers --- .../clinical-dashboard/answer-content.tsx | 42 ++++--- ...forward_codify_retrieval_owner_matches.sql | 106 ------------------ tests/answer-content.test.ts | 10 ++ 3 files changed, 36 insertions(+), 122 deletions(-) delete mode 100644 supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql create mode 100644 tests/answer-content.test.ts diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 8b7edc725..9fed3de97 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -232,7 +232,7 @@ export type AnswerDisplayTextOptions = { // compact 3-fragment / 85-word cap — a withhold/threshold/escalation caveat // hidden from the primary prose is a clinical-safety regression. const primaryAnswerSafetySignalPattern = - /\b(?:withhold|withheld|stop|cease|hold|threshold|escalat|urgent|red\s*zone|amber|immediately|do not|contraindicat|toxic)\b/i; + /\b(?:withhold|withheld|stop|cease|hold|threshold|escalat\w*|urgent|red\s*zone|amber|immediately|do not|contraindicat\w*|toxic)\b/i; export function plainAnswerText(value: string, options: AnswerDisplayTextOptions = {}) { // clinicalProseUsefulness runs the source-noise stripper, so preformatted @@ -248,7 +248,7 @@ export function plainAnswerText(value: string, options: AnswerDisplayTextOptions .trim(); } -function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) { +export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) { const cleaned = plainAnswerText(value, options); // Deterministic preformatted answers are already concise and display-ready; // the fragment-level usefulness pass below would re-strip the very names/codes @@ -275,21 +275,31 @@ function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptio return useful.useful || fragment.split(/\s+/).length >= 8; }); const uniqueFragments = Array.from(new Set(fragments)); - // Compact head (unchanged when no later fragment is safety-critical), plus any - // safety-signal fragment beyond the head appended in full so the cap can never - // hide it. When safetyTail is empty this is byte-identical to the prior cap. - const head = uniqueFragments.slice(0, 3); - const safetyTail = uniqueFragments.slice(3).filter((fragment) => primaryAnswerSafetySignalPattern.test(fragment)); - const headWords = head.join(" ").split(/\s+/).filter(Boolean); - const headText = - headWords.length <= 85 - ? head.join(" ") - : `${headWords - .slice(0, 85) + const selected: string[] = []; + let nonSafetyKept = 0; + let wordBudget = 85; + for (const fragment of uniqueFragments) { + if (primaryAnswerSafetySignalPattern.test(fragment)) { + selected.push(fragment); + continue; + } + if (nonSafetyKept >= 3 || wordBudget <= 0) continue; + nonSafetyKept += 1; + const words = fragment.split(/\s+/).filter(Boolean); + if (words.length <= wordBudget) { + selected.push(fragment); + wordBudget -= words.length; + } else { + selected.push( + `${words + .slice(0, wordBudget) .join(" ") - .replace(/[;,:-]\s*$/, "")}...`; - const selected = [headText, ...safetyTail].filter(Boolean).join(" "); - return selected || cleaned; + .replace(/[;,:-]\s*$/, "")}...`, + ); + wordBudget = 0; + } + } + return selected.join(" ") || cleaned; } // One compact "Sources" pill in every state: the amber Source-only pill and the diff --git a/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql deleted file mode 100644 index 00dff3de7..000000000 --- a/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql +++ /dev/null @@ -1,106 +0,0 @@ --- Forward-codify the live retrieval RPC bodies so committed migrations reproduce --- production. Drift backlog item from 20260705210000_retrieval_owner_filter_sentinel.sql. --- --- VERIFIED LIVE STATE (project sjrfecxgysukkwxsowpy "Clinical KB Database", --- read-only pg_get_functiondef inspection, 2026-07-12) --- ------------------------------------------------------------------------------ --- ALL EIGHT primary retrieval RPCs on live ALREADY gate ownership with the --- fail-closed + sentinel-aware helper retrieval_owner_matches(owner_filter, --- d.owner_id) — none carry the legacy fail-open inline predicate: --- match_document_chunks_hybrid(vector,text,integer,double precision,uuid[],uuid) --- match_document_chunks_text(text,integer,uuid[],uuid) --- match_document_lookup_chunks_text(text,uuid[],integer,uuid) --- match_document_table_facts_text(text,integer,uuid[],uuid) --- match_document_embedding_fields_hybrid(vector,text,integer,double precision,uuid[],uuid) --- match_document_index_units_hybrid(vector,text,integer,double precision,uuid[],uuid) --- match_documents_for_query(text,integer,uuid) --- match_document_chunks(vector,integer,double precision,uuid,uuid) --- So there is NO live tenancy hole. The gap is reproducibility only: the --- COMMITTED migration bodies are stale (fail-open + sentinel-blind), so any --- environment rebuilt from source — Supabase preview branch, `db reset`, DR --- restore, a new region, or the golden retrieval eval against a fresh DB — gets --- empty public retrieval plus a fail-open NULL branch that live does not have. --- --- TWO NON-PRIMARY functions still carry a fail-open-shaped inline predicate on --- live (assess separately — NOT part of the primary-retrieval codification): --- * match_document_embedding_fields_text(text,integer,double precision,uuid[],uuid) --- — sole gate is `(owner_filter is null or d.owner_id = owner_filter)`: --- genuine latent fail-open on NULL + sentinel-blind. Confirm whether the --- app calls this text-only variant (the app uses the _hybrid variant); --- if live, route it through retrieval_owner_matches too. --- * get_related_document_metadata(uuid[],uuid) --- — the primary `documents d` join is fail-closed via --- retrieval_owner_matches, but the label (l) and summary (s) joins keep --- `(owner_filter is null or …)`. Backstopped by the fail-closed d gate --- (NULL → no d rows → no l/s rows), so not a live escape, but tidy up --- for consistency. --- --- WHY THIS FILE IS NOT HAND-FILLED WITH THE RPC BODIES --- ------------------------------------------------------------------------------ --- The live bodies total ~26 KB across 8 functions and have diverged FORWARD from --- committed source (hnsw.ef_search wrapper on match_document_chunks; richer --- multi-strategy _text/_table_facts_text bodies; left-join quality_score on the --- hybrid path — see 20260705210000). They must be codified BYTE-FOR-BYTE from --- live: a transcription error or a stale-source rebuild would REGRESS retrieval, --- the exact trap 20260705210000 was neutralized to prevent. That requires a --- direct DB dump (the environment running this review has no Postgres connection --- string — only PostgREST keys — so it cannot produce a byte-perfect dump). This --- is owner-driven, matching the documented `supabase migration repair` drift --- workflow. --- --- OWNER COMPLETION (byte-perfect, no hand transcription; each step confirmed): --- ------------------------------------------------------------------------------ --- 1. With the linked project + DB connection string ($SUPABASE_DB_URL), append --- the EXACT live bodies to this file: --- --- psql "$SUPABASE_DB_URL" -X -q -At -o /tmp/rpc_bodies.sql -c " --- select string_agg(pg_get_functiondef(p.oid) || E';\n', E'\n' order by p.proname, p.oid) --- from pg_proc p join pg_namespace n on n.oid = p.pronamespace --- where n.nspname = 'public' and p.proname in ( --- 'match_document_chunks_hybrid','match_document_chunks_text', --- 'match_document_lookup_chunks_text','match_document_table_facts_text', --- 'match_document_embedding_fields_hybrid','match_document_index_units_hybrid', --- 'match_documents_for_query','match_document_chunks');" --- cat /tmp/rpc_bodies.sql >> supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql --- --- (`create or replace function` is idempotent; applying on live is a no-op --- because the bodies already match. On fresh rebuilds it installs them.) --- 2. Apply on a Supabase PREVIEW BRANCH. The DO $verify$ guard below must pass, --- then run: npm run eval:retrieval:quality -- --fail-on-threshold (36/36) --- to prove the rebuilt retrieval matches live before this reaches main. --- 3. Only after a green preview + golden eval, land + apply. - -set search_path = public, extensions, pg_temp; - --- Safe, idempotent guard: re-assert the fail-closed + sentinel-aware truth table --- for retrieval_owner_matches so a bad redefinition (or an environment where the --- helper drifted back to fail-open) fails this migration loudly instead of --- silently shipping a tenancy regression. Mirrors 20260708160001. -do $verify$ -declare - a uuid := '11111111-1111-1111-1111-111111111111'; - b uuid := '22222222-2222-2222-2222-222222222222'; - sentinel uuid := '00000000-0000-0000-0000-000000000000'; -begin - if public.retrieval_owner_matches(null, a) is not false then - raise exception 'retrieval_owner_matches(NULL, uuid) must be FALSE (fail-closed)'; - end if; - if public.retrieval_owner_matches(null, null) is not false then - raise exception 'retrieval_owner_matches(NULL, NULL) must be FALSE (fail-closed)'; - end if; - if public.retrieval_owner_matches(sentinel, null) is not true then - raise exception 'retrieval_owner_matches(sentinel, NULL) must be TRUE (public row)'; - end if; - if public.retrieval_owner_matches(sentinel, a) is not false then - raise exception 'retrieval_owner_matches(sentinel, owned) must be FALSE (public-only)'; - end if; - if public.retrieval_owner_matches(a, a) is not true then - raise exception 'retrieval_owner_matches(owner, same owner) must be TRUE'; - end if; - if public.retrieval_owner_matches(a, b) is not false then - raise exception 'retrieval_owner_matches(owner, other owner) must be FALSE'; - end if; -end -$verify$; - --- >>> OWNER: append the byte-perfect live RPC bodies below this line (step 1). <<< diff --git a/tests/answer-content.test.ts b/tests/answer-content.test.ts new file mode 100644 index 000000000..de969bc66 --- /dev/null +++ b/tests/answer-content.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { primaryAnswerDisplayText } from "../src/components/clinical-dashboard/answer-content"; + +describe("primaryAnswerDisplayText", () => { + it("keeps a safety cue in a long leading fragment beyond the compact word budget", () => { + const lead = `${Array.from({ length: 90 }, (_, index) => `detail${index + 1}`).join(" ")} Do not administer the medicine.`; + + expect(primaryAnswerDisplayText(lead)).toContain("Do not administer the medicine."); + }); +}); From eca2c84f1d241c4cbb282b8d8f621b2201afbc92 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:30:33 +0800 Subject: [PATCH 05/11] fix(rag): keep short safety caveats past the usefulness/length filter The compact-answer safety loop only rescued fragments that survived the `.map`/`.filter` usefulness gate, so a short caveat like "Contraindicated in pregnancy" (7 words, not flagged "useful") was dropped before it could be preserved. Make the map/filter safety-aware so safety-bearing fragments pass through untouched, and add regression tests for short and full-word escalation/contraindication caveats beyond the compact head. Co-Authored-By: Claude Opus 4.8 --- .../clinical-dashboard/answer-content.tsx | 8 ++++++- tests/answer-content.test.ts | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 96cd8ea2f..d4377f055 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -288,9 +288,15 @@ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTe ) .trim(), ) - .map((fragment: string) => clinicalProseUsefulness(fragment).text || fragment) + // Safety-bearing fragments pass through untouched and are never dropped by + // the usefulness/length gate — a short caveat like "Contraindicated in + // pregnancy" (under the 8-word floor) must still reach the display. + .map((fragment: string) => + primaryAnswerSafetySignalPattern.test(fragment) ? fragment : clinicalProseUsefulness(fragment).text || fragment, + ) .filter((fragment: string) => { if (!fragment) return false; + if (primaryAnswerSafetySignalPattern.test(fragment)) return true; const useful = clinicalProseUsefulness(fragment); return useful.useful || fragment.split(/\s+/).length >= 8; }); diff --git a/tests/answer-content.test.ts b/tests/answer-content.test.ts index de969bc66..3e113d878 100644 --- a/tests/answer-content.test.ts +++ b/tests/answer-content.test.ts @@ -7,4 +7,25 @@ describe("primaryAnswerDisplayText", () => { expect(primaryAnswerDisplayText(lead)).toContain("Do not administer the medicine."); }); + + it("keeps a full-word escalation caveat that appears beyond the compact head", () => { + // A bare `escalat\b` stem never matched "Escalate", so this caveat (the + // fourth fragment) was silently dropped before the fix. + const answer = + "Give paracetamol for ongoing pain. Review the observations hourly overnight. Document the management plan clearly in the notes. Escalate to the senior doctor if the patient deteriorates."; + expect(primaryAnswerDisplayText(answer)).toContain("Escalate to the senior doctor"); + }); + + it("keeps a short contraindication caveat under the 8-word usefulness floor", () => { + // "Contraindicated in pregnancy and severe renal impairment" is 7 words, so + // the usefulness/length filter dropped it before the safety-aware exception. + const answer = + "Offer oral rehydration first. Reassess fluid balance after two hours. Record the intake and output totals. Contraindicated in pregnancy and severe renal impairment."; + expect(primaryAnswerDisplayText(answer)).toContain("Contraindicated in pregnancy"); + }); + + it("is unchanged for a short answer with no safety signal", () => { + const answer = "Offer simple analgesia and reassess in one hour."; + expect(primaryAnswerDisplayText(answer)).toBe(answer); + }); }); From 79a896d00c295d63e2ec4ec65d6c0ba116024861 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:45:12 +0800 Subject: [PATCH 06/11] style: format source-text-sanitizer.test.ts (CodeRabbit autofix left it unformatted) Co-Authored-By: Claude Opus 4.8 --- tests/source-text-sanitizer.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/source-text-sanitizer.test.ts b/tests/source-text-sanitizer.test.ts index d7c4f81d9..a0936234a 100644 --- a/tests/source-text-sanitizer.test.ts +++ b/tests/source-text-sanitizer.test.ts @@ -536,7 +536,9 @@ describe("normalizeInlineBulletGlyphs", () => { ); // Blood-value + noun forms with bold markers. - expect(normalizeInlineBulletGlyphs("o **Negative** blood should be used")).toBe("o **Negative** blood should be used"); + expect(normalizeInlineBulletGlyphs("o **Negative** blood should be used")).toBe( + "o **Negative** blood should be used", + ); expect(normalizeInlineBulletGlyphs("o **RhD negative** red cells are required")).toBe( "o **RhD negative** red cells are required", ); From 10f39fd8a92e03aab569c79e9af29aa11c626d3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 12 Jul 2026 17:50:37 +0000 Subject: [PATCH 07/11] fix(ci): resolve PR #514 review findings and Supabase preview drift - Skip whole-text clinicalProseUsefulness in primaryAnswerDisplayText so short safety sentences (Stop/Cease/held) survive to the fragment bypass - Add held to the safety signal matcher for passive withhold caveats - Strip catalogue/source artifacts on a de-bolded copy when preserveBold is set, then restore surviving emphasis spans - Restore the 20260712000000 migration stub so the Supabase preview branch matches local migration history (verify guard only; RPC bodies deferred) - Add regression tests for stop/held caveats and bolded catalogue stripping Co-authored-by: BigSimmo --- .../clinical-dashboard/answer-content.tsx | 16 ++- src/lib/rag-answer-text.ts | 47 +++++--- ...forward_codify_retrieval_owner_matches.sql | 105 ++++++++++++++++++ tests/answer-content.test.ts | 12 ++ tests/rag-answer-text.test.ts | 11 ++ 5 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index d4377f055..4325f415a 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -238,7 +238,7 @@ export type AnswerDisplayTextOptions = { // compact 3-fragment / 85-word cap — a withhold/threshold/escalation caveat // hidden from the primary prose is a clinical-safety regression. const primaryAnswerSafetySignalPattern = - /\b(?:withhold|withheld|stop|cease|hold|threshold|escalat\w*|urgent|red\s*zone|amber|immediately|do not|contraindicat\w*|toxic)\b/i; + /\b(?:withhold|withheld|stop|cease|hold|held|threshold|escalat\w*|urgent|red\s*zone|amber|immediately|do not|contraindicat\w*|toxic)\b/i; /** * Produces sanitized, display-ready text for an answer. @@ -269,11 +269,21 @@ export function plainAnswerText(value: string, options: AnswerDisplayTextOptions * @returns The display-ready answer text */ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) { - const cleaned = plainAnswerText(value, options); // Deterministic preformatted answers are already concise and display-ready; // the fragment-level usefulness pass below would re-strip the very names/codes // the preformatted path just preserved, so return them as-is. - if (options.preformatted) return cleaned; + if (options.preformatted) return plainAnswerText(value, options); + // Skip whole-text clinicalProseUsefulness: its 3-token floor drops short + // safety sentences ("Stop lithium.") before the fragment-level safety + // bypass below can rescue them. + const cleaned = sanitizeAnswerDisplayText(value, { + minLength: 8, + minTokens: 2, + preformatted: false, + preserveBold: options.preserveBold, + }) + .replace(/(?:\s*\n\s*)?Synthetic demo only:.*$/i, "") + .trim(); const fragments = cleaned .split(/\r?\n+/) .flatMap((line: string) => diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts index 418954660..de42c1c13 100644 --- a/src/lib/rag-answer-text.ts +++ b/src/lib/rag-answer-text.ts @@ -217,18 +217,8 @@ function separateSettingRunOns(value: string): string { * @param options.preserveBold - Whether to preserve inline bold markers * @returns The polished clinical answer text */ -export function polishClinicalAnswerProse(value: string, options: { preserveBold?: boolean } = {}) { - // Bold markers normally come off before bullet normalization so an emphasized - // item ("o **Reduce dose**") still reads as a sub-bullet to the "o" matcher. - // The display path passes preserveBold so can render the - // server's high-yield emphasis (and the un-bold unverified-number safety - // signal); the sub-bullet matcher tolerates a leading "**" so bolded - // sub-bullets still normalize. The server answer-gen path keeps the default - // (strip), so its quality gates are unchanged. - const normalized = normalizeSectionText(value); - const cleaned = normalizeInlineBulletGlyphs( - options.preserveBold ? normalized : normalized.replace(/\*\*([^*]+)\*\*/g, "$1"), - ) +function stripAnswerArtifactNoise(text: string) { + return text .replace(productCatalogueFragmentPattern, " ") .replace(brandOrFormularyFragmentPattern, " ") .replace(imprestLocationPattern, " ") @@ -245,10 +235,41 @@ export function polishClinicalAnswerProse(value: string, options: { preserveBold .replace(/(?:\.\s*){2,}/g, ". ") .replace(/\s+/g, " ") .trim(); +} + +function restoreBoldSpans(text: string, boldSpans: string[]) { + let restored = text; + for (const span of boldSpans) { + if (restored.includes(span) && !restored.includes(`**${span}**`)) { + restored = restored.replace(span, `**${span}**`); + } + } + return restored; +} + +export function polishClinicalAnswerProse(value: string, options: { preserveBold?: boolean } = {}) { + // Bold markers normally come off before bullet normalization so an emphasized + // item ("o **Reduce dose**") still reads as a sub-bullet to the "o" matcher. + // The display path passes preserveBold so can render the + // server's high-yield emphasis (and the un-bold unverified-number safety + // signal); the sub-bullet matcher tolerates a leading "**" so bolded + // sub-bullets still normalize. The server answer-gen path keeps the default + // (strip), so its quality gates are unchanged. + const normalized = normalizeSectionText(value); + const boldSpans = [...normalized.matchAll(/\*\*([^*]+)\*\*/g)].map((match) => match[1]); + const bulletNormalized = normalizeInlineBulletGlyphs( + options.preserveBold ? normalized : normalized.replace(/\*\*([^*]+)\*\*/g, "$1"), + ); + // Artifact regexes were written for unbolded catalogue/source noise; run + // them on a de-bolded copy so preserveBold still strips Lithicarb-style junk. + const deBolded = bulletNormalized.replace(/\*\*([^*]+)\*\*/g, "$1"); + const cleaned = stripAnswerArtifactNoise(deBolded); - return normalizeGenericMedicationCase( + const polished = normalizeGenericMedicationCase( separateSettingRunOns(removeOrphanAnswerHeadings(removeBadAnswerFragments(cleaned))), ); + + return options.preserveBold ? restoreBoldSpans(polished, boldSpans) : polished; } export function sanitizeAnswerText(value: string) { diff --git a/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql new file mode 100644 index 000000000..61dd9b10e --- /dev/null +++ b/supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql @@ -0,0 +1,105 @@ +-- Forward-codify the live retrieval RPC bodies so committed migrations reproduce +-- production. Drift backlog item from 20260705210000_retrieval_owner_filter_sentinel.sql. +-- +-- VERIFIED LIVE STATE (read-only pg_get_functiondef inspection, 2026-07-12) +-- ------------------------------------------------------------------------------ +-- ALL EIGHT primary retrieval RPCs on live ALREADY gate ownership with the +-- fail-closed + sentinel-aware helper retrieval_owner_matches(owner_filter, +-- d.owner_id) — none carry the legacy fail-open inline predicate: +-- match_document_chunks_hybrid(vector,text,integer,double precision,uuid[],uuid) +-- match_document_chunks_text(text,integer,uuid[],uuid) +-- match_document_lookup_chunks_text(text,uuid[],integer,uuid) +-- match_document_table_facts_text(text,integer,uuid[],uuid) +-- match_document_embedding_fields_hybrid(vector,text,integer,double precision,uuid[],uuid) +-- match_document_index_units_hybrid(vector,text,integer,double precision,uuid[],uuid) +-- match_documents_for_query(text,integer,uuid) +-- match_document_chunks(vector,integer,double precision,uuid,uuid) +-- So there is NO live tenancy hole. The gap is reproducibility only: the +-- COMMITTED migration bodies are stale (fail-open + sentinel-blind), so any +-- environment rebuilt from source — Supabase preview branch, `db reset`, DR +-- restore, a new region, or the golden retrieval eval against a fresh DB — gets +-- empty public retrieval plus a fail-open NULL branch that live does not have. +-- +-- TWO NON-PRIMARY functions still carry a fail-open-shaped inline predicate on +-- live (assess separately — NOT part of the primary-retrieval codification): +-- * match_document_embedding_fields_text(text,integer,double precision,uuid[],uuid) +-- — sole gate is `(owner_filter is null or d.owner_id = owner_filter)`: +-- genuine latent fail-open on NULL + sentinel-blind. Confirm whether the +-- app calls this text-only variant (the app uses the _hybrid variant); +-- if live, route it through retrieval_owner_matches too. +-- * get_related_document_metadata(uuid[],uuid) +-- — the primary `documents d` join is fail-closed via +-- retrieval_owner_matches, but the label (l) and summary (s) joins keep +-- `(owner_filter is null or …)`. Backstopped by the fail-closed d gate +-- (NULL → no d rows → no l/s rows), so not a live escape, but tidy up +-- for consistency. +-- +-- WHY THIS FILE IS NOT HAND-FILLED WITH THE RPC BODIES +-- ------------------------------------------------------------------------------ +-- The live bodies total ~26 KB across 8 functions and have diverged FORWARD from +-- committed source (hnsw.ef_search wrapper on match_document_chunks; richer +-- multi-strategy _text/_table_facts_text bodies; left-join quality_score on the +-- hybrid path — see 20260705210000). They must be codified BYTE-FOR-BYTE from +-- live: a transcription error or a stale-source rebuild would REGRESS retrieval, +-- the exact trap 20260705210000 was neutralized to prevent. That requires a +-- direct DB dump (the environment running this review has no Postgres connection +-- string — only PostgREST keys — so it cannot produce a byte-perfect dump). This +-- is owner-driven, matching the documented `supabase migration repair` drift +-- workflow. +-- +-- OWNER COMPLETION (byte-perfect, no hand transcription; each step confirmed): +-- ------------------------------------------------------------------------------ +-- 1. With the linked project + DB connection string ($SUPABASE_DB_URL), append +-- the EXACT live bodies to this file: +-- +-- psql "$SUPABASE_DB_URL" -X -q -At -o /tmp/rpc_bodies.sql -c " +-- select string_agg(pg_get_functiondef(p.oid) || E';\n', E'\n' order by p.proname, p.oid) +-- from pg_proc p join pg_namespace n on n.oid = p.pronamespace +-- where n.nspname = 'public' and p.proname in ( +-- 'match_document_chunks_hybrid','match_document_chunks_text', +-- 'match_document_lookup_chunks_text','match_document_table_facts_text', +-- 'match_document_embedding_fields_hybrid','match_document_index_units_hybrid', +-- 'match_documents_for_query','match_document_chunks');" +-- cat /tmp/rpc_bodies.sql >> supabase/migrations/20260712000000_forward_codify_retrieval_owner_matches.sql +-- +-- (`create or replace function` is idempotent; applying on live is a no-op +-- because the bodies already match. On fresh rebuilds it installs them.) +-- 2. Apply on a Supabase PREVIEW BRANCH. The DO $verify$ guard below must pass, +-- then run: npm run eval:retrieval:quality -- --fail-on-threshold (36/36) +-- to prove the rebuilt retrieval matches live before this reaches main. +-- 3. Only after a green preview + golden eval, land + apply. + +set search_path = public, extensions, pg_temp; + +-- Safe, idempotent guard: re-assert the fail-closed + sentinel-aware truth table +-- for retrieval_owner_matches so a bad redefinition (or an environment where the +-- helper drifted back to fail-open) fails this migration loudly instead of +-- silently shipping a tenancy regression. Mirrors 20260708160001. +do $verify$ +declare + a uuid := '11111111-1111-1111-1111-111111111111'; + b uuid := '22222222-2222-2222-2222-222222222222'; + sentinel uuid := '00000000-0000-0000-0000-000000000000'; +begin + if public.retrieval_owner_matches(null, a) is not false then + raise exception 'retrieval_owner_matches(NULL, uuid) must be FALSE (fail-closed)'; + end if; + if public.retrieval_owner_matches(null, null) is not false then + raise exception 'retrieval_owner_matches(NULL, NULL) must be FALSE (fail-closed)'; + end if; + if public.retrieval_owner_matches(sentinel, null) is not true then + raise exception 'retrieval_owner_matches(sentinel, NULL) must be TRUE (public row)'; + end if; + if public.retrieval_owner_matches(sentinel, a) is not false then + raise exception 'retrieval_owner_matches(sentinel, owned) must be FALSE (public-only)'; + end if; + if public.retrieval_owner_matches(a, a) is not true then + raise exception 'retrieval_owner_matches(owner, same owner) must be TRUE'; + end if; + if public.retrieval_owner_matches(a, b) is not false then + raise exception 'retrieval_owner_matches(owner, other owner) must be FALSE'; + end if; +end +$verify$; + +-- >>> OWNER: append the byte-perfect live RPC bodies below this line (step 1). <<< diff --git a/tests/answer-content.test.ts b/tests/answer-content.test.ts index 3e113d878..310862887 100644 --- a/tests/answer-content.test.ts +++ b/tests/answer-content.test.ts @@ -24,6 +24,18 @@ describe("primaryAnswerDisplayText", () => { expect(primaryAnswerDisplayText(answer)).toContain("Contraindicated in pregnancy"); }); + it("keeps a short stop instruction after normal leading prose", () => { + const answer = + "Give paracetamol for ongoing pain. Review the observations hourly overnight. Document the management plan clearly in the notes. Stop lithium."; + expect(primaryAnswerDisplayText(answer)).toContain("Stop lithium."); + }); + + it("keeps a passive held caveat beyond the compact head", () => { + const answer = + "Give paracetamol for ongoing pain. Review the observations hourly overnight. Document the management plan clearly in the notes. Clozapine should be held."; + expect(primaryAnswerDisplayText(answer)).toContain("should be held"); + }); + it("is unchanged for a short answer with no safety signal", () => { const answer = "Offer simple analgesia and reassess in one hour."; expect(primaryAnswerDisplayText(answer)).toBe(answer); diff --git a/tests/rag-answer-text.test.ts b/tests/rag-answer-text.test.ts index 8e418b8cc..ca7c46bfe 100644 --- a/tests/rag-answer-text.test.ts +++ b/tests/rag-answer-text.test.ts @@ -88,6 +88,17 @@ describe("RAG answer text helpers", () => { expect(cleaned).not.toMatch(/Lithicarb|Quilonum|Imprest|DOSAGE|Dose evidence|Tests1/i); }); + it("strips bolded catalogue noise when preserveBold keeps clinical emphasis", () => { + const noisy = + "Dose evidence: **Lithium** Carbonate **250 mg** Tablet - Lithicarb®. Therapy with **lithium** should always begin with conventional tablets (**lithium** carbonate **250 mg**)."; + + const cleaned = polishClinicalAnswerProse(noisy, { preserveBold: true }); + + expect(cleaned).toContain("Therapy with **lithium**"); + expect(cleaned).toContain("**250 mg**"); + expect(cleaned).not.toMatch(/Lithicarb|Dose evidence/i); + }); + it("flags source-inventory wording and truncated clinical fragments as answer quality issues", () => { expect( hasClinicalAnswerQualityIssue( From 3016507b2385b55d2514bb30db9b71e6e2bf9050 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:31:39 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Simpl?= =?UTF-8?q?ify=20PR=20code=20(#534)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/components/ClinicalDashboard.tsx | 5 +- .../clinical-dashboard/answer-content.tsx | 46 ++++++++++++------- .../answer-result-surface.tsx | 8 +++- .../clinical-dashboard/evidence-panels.tsx | 5 +- .../clinical-dashboard/output-panel.tsx | 6 +-- src/lib/rag-answer-text.ts | 16 +++---- 6 files changed, 51 insertions(+), 35 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index c936b192c..4007a3a7a 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -68,6 +68,7 @@ import { import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; import { + isPreformattedGroundedAnswer, NaturalLanguageAnswer, ScopeAndGovernanceNotice, UserQuestionBubble, @@ -523,7 +524,7 @@ function PriorAnswerTurnSurface({ () => buildAnswerRenderModel(turn.answer, { sources: turn.sources }), [turn.answer, turn.sources], ); - const turnPreformatted = Boolean(turn.answer.preformatted && turn.answer.grounded); + const turnPreformatted = isPreformattedGroundedAnswer(turn.answer); const safeText = useMemo( () => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }), [turn.answer.answer, turnPreformatted], @@ -2861,7 +2862,7 @@ export function ClinicalDashboard({ currentRelevance?.isSourceBacked !== false && answerRenderModel?.trust !== "unsupported"; const sourceLookup = useMemo(() => new Map(sources.map((source) => [source.id, source])), [sources]); - const answerPreformatted = Boolean(answer?.preformatted && answer?.grounded); + const answerPreformatted = isPreformattedGroundedAnswer(answer); const safeAnswerText = useMemo( () => sanitizeAnswerDisplayText(answer?.answer ?? "", { preformatted: answerPreformatted }), [answer?.answer, answerPreformatted], diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 71eabd774..cebd733b7 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -53,6 +53,7 @@ import type { AnswerSection, AnswerSectionKind, BestSourceRecommendation, + RagAnswer, SearchResult, SearchScopeSummary, VisualEvidenceCard, @@ -234,12 +235,39 @@ export type AnswerDisplayTextOptions = { preserveBold?: boolean; }; +/** + * Reports whether an answer is a server-`preformatted`, grounded answer whose + * display text should bypass prose sanitization and be shown as-is. + * + * @param answer - The answer to check, or `null`/`undefined` when unavailable + * @returns `true` when the answer is preformatted and grounded + */ +export function isPreformattedGroundedAnswer( + answer: Pick | null | undefined, +) { + return Boolean(answer?.preformatted && answer?.grounded); +} + // Fragments carrying a safety-critical signal must never be dropped by the // compact 3-fragment / 85-word cap — a withhold/threshold/escalation caveat // hidden from the primary prose is a clinical-safety regression. const primaryAnswerSafetySignalPattern = /\b(?:withhold|withheld|stop|cease|hold|held|threshold|escalat\w*|urgent|red\s*zone|amber|immediately|do not|contraindicat\w*|toxic)\b/i; +// Shared tail of the sanitize path: run the display sanitizer, then strip the +// synthetic-demo notice both plainAnswerText and primaryAnswerDisplayText need +// removed before the text reaches the screen. +function sanitizeAndStripSyntheticNotice(value: string, options: AnswerDisplayTextOptions) { + return sanitizeAnswerDisplayText(value, { + minLength: 8, + minTokens: 2, + preformatted: options.preformatted, + preserveBold: options.preserveBold, + }) + .replace(/(?:\s*\n\s*)?Synthetic demo only:.*$/i, "") + .trim(); +} + /** * Produces sanitized, display-ready text for an answer. * @@ -251,14 +279,7 @@ export function plainAnswerText(value: string, options: AnswerDisplayTextOptions // clinicalProseUsefulness runs the source-noise stripper, so preformatted // answers bypass it and go straight to the lossless display path. const base = options.preformatted ? value : clinicalProseUsefulness(value).text || value; - return sanitizeAnswerDisplayText(base, { - minLength: 8, - minTokens: 2, - preformatted: options.preformatted, - preserveBold: options.preserveBold, - }) - .replace(/(?:\s*\n\s*)?Synthetic demo only:.*$/i, "") - .trim(); + return sanitizeAndStripSyntheticNotice(base, options); } /** @@ -276,14 +297,7 @@ export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTe // Skip whole-text clinicalProseUsefulness: its 3-token floor drops short // safety sentences ("Stop lithium.") before the fragment-level safety // bypass below can rescue them. - const cleaned = sanitizeAnswerDisplayText(value, { - minLength: 8, - minTokens: 2, - preformatted: false, - preserveBold: options.preserveBold, - }) - .replace(/(?:\s*\n\s*)?Synthetic demo only:.*$/i, "") - .trim(); + const cleaned = sanitizeAndStripSyntheticNotice(value, { preformatted: false, preserveBold: options.preserveBold }); const fragments = cleaned .split(/\r?\n+/) .flatMap((line: string) => diff --git a/src/components/clinical-dashboard/answer-result-surface.tsx b/src/components/clinical-dashboard/answer-result-surface.tsx index f410d9e8a..752886401 100644 --- a/src/components/clinical-dashboard/answer-result-surface.tsx +++ b/src/components/clinical-dashboard/answer-result-surface.tsx @@ -7,7 +7,11 @@ import { ClipboardCheck, ExternalLink, Layers, ShieldAlert } from "lucide-react" import { type AnswerFeedbackType } from "@/lib/answer-feedback"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; -import { NaturalLanguageAnswer, UserQuestionBubble } from "@/components/clinical-dashboard/answer-content"; +import { + isPreformattedGroundedAnswer, + NaturalLanguageAnswer, + UserQuestionBubble, +} from "@/components/clinical-dashboard/answer-content"; import { AnswerSupportSummaryCard, answerHasCentralTable, @@ -215,7 +219,7 @@ function StagedAnswerResultSurfaceImpl({
section.id === "bottom-line") ?? sections[0]; - const primaryAnswer = plainAnswerText(answer.answer, { - preformatted: Boolean(answer.preformatted && answer.grounded), - }); + const primaryAnswer = plainAnswerText(answer.answer, { preformatted: isPreformattedGroundedAnswer(answer) }); const detailSections = sections .filter((section) => section.id !== "verify-source") .filter((section) => (showLead ? section.id !== leadSection?.id : section.id !== "bottom-line")) diff --git a/src/lib/rag-answer-text.ts b/src/lib/rag-answer-text.ts index de42c1c13..7996b4370 100644 --- a/src/lib/rag-answer-text.ts +++ b/src/lib/rag-answer-text.ts @@ -209,14 +209,6 @@ function separateSettingRunOns(value: string): string { .replace(/\bfor community patients,?\s+for inpatients,?/gi, "for community patients. For inpatients,"); } -/** - * Cleans clinical answer text by removing artifacts, normalizing formatting, and improving readability. - * - * @param value - The clinical answer text to polish - * @param options - Formatting options - * @param options.preserveBold - Whether to preserve inline bold markers - * @returns The polished clinical answer text - */ function stripAnswerArtifactNoise(text: string) { return text .replace(productCatalogueFragmentPattern, " ") @@ -247,6 +239,14 @@ function restoreBoldSpans(text: string, boldSpans: string[]) { return restored; } +/** + * Cleans clinical answer text by removing artifacts, normalizing formatting, and improving readability. + * + * @param value - The clinical answer text to polish + * @param options - Formatting options + * @param options.preserveBold - Whether to preserve inline bold markers + * @returns The polished clinical answer text + */ export function polishClinicalAnswerProse(value: string, options: { preserveBold?: boolean } = {}) { // Bold markers normally come off before bullet normalization so an emphasized // item ("o **Reduce dose**") still reads as a sub-bullet to the "o" matcher. From 5d8309935203534f0af02da2e008a9ffa4b54b40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Jul 2026 18:37:04 +0000 Subject: [PATCH 09/11] fix: apply Prettier formatting to answer-content.tsx --- src/components/clinical-dashboard/answer-content.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index cebd733b7..8e8ab5ba0 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -242,9 +242,7 @@ export type AnswerDisplayTextOptions = { * @param answer - The answer to check, or `null`/`undefined` when unavailable * @returns `true` when the answer is preformatted and grounded */ -export function isPreformattedGroundedAnswer( - answer: Pick | null | undefined, -) { +export function isPreformattedGroundedAnswer(answer: Pick | null | undefined) { return Boolean(answer?.preformatted && answer?.grounded); } From fdff7d94e6a95b02ba1bd056b10e19c72c737fc1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:55:10 +0800 Subject: [PATCH 10/11] fix(rag): harden compact-answer safety matcher (avoid + bolded phrases) - Add 'avoid' to the safety-signal matcher so short avoidance directives ('Avoid lithium in pregnancy.') are preserved through usefulness/length filtering and the compact cap (Codex review). - Test the safety matcher against a de-bolded copy of the fragment via a new isPrimaryAnswerSafetyFragment helper, so server bold markers inside a phrase ('Do **not** administer', 'red **zone**') on the preserveBold path can no longer defeat the match and let a caveat be dropped (Codex P1). - Add regression tests for the avoid directive and the bolded-keyword caveat. Co-Authored-By: Claude Opus 4.8 --- .../clinical-dashboard/answer-content.tsx | 15 +++++++++++---- tests/answer-content.test.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index 8e8ab5ba0..66900c72d 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -250,7 +250,14 @@ export function isPreformattedGroundedAnswer(answer: Pick