Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e87c446
fix(rag): honor preformatted answers, render high-yield bold, guard s…
BigSimmo Jul 12, 2026
d5ab4c3
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] Jul 12, 2026
65e5575
📝 Add docstrings to `claude/rag-review-improvements-f1bf84`
coderabbitai[bot] Jul 12, 2026
71bd4c6
fix: preserve safety cues in compact answers
BigSimmo Jul 12, 2026
af8d861
merge: retain RAG documentation updates
BigSimmo Jul 12, 2026
eca2c84
fix(rag): keep short safety caveats past the usefulness/length filter
BigSimmo Jul 12, 2026
79a896d
style: format source-text-sanitizer.test.ts (CodeRabbit autofix left …
BigSimmo Jul 12, 2026
10f39fd
fix(ci): resolve PR #514 review findings and Supabase preview drift
cursoragent Jul 12, 2026
d2551fb
Merge branch 'main' into claude/rag-review-improvements-f1bf84
BigSimmo Jul 12, 2026
3016507
📝 CodeRabbit Chat: Simplify PR code (#534)
coderabbitai[bot] Jul 12, 2026
2ebd2bf
Merge branch 'main' into claude/rag-review-improvements-f1bf84
BigSimmo Jul 12, 2026
5d83099
fix: apply Prettier formatting to answer-content.tsx
Copilot Jul 12, 2026
dac19c9
Merge branch 'main' into claude/rag-review-improvements-f1bf84
BigSimmo Jul 12, 2026
99beaec
Merge branch 'main' into claude/rag-review-improvements-f1bf84
BigSimmo Jul 12, 2026
fdff7d9
fix(rag): harden compact-answer safety matcher (avoid + bolded phrases)
BigSimmo Jul 12, 2026
1eb2c03
Merge branch 'main' into claude/rag-review-improvements-f1bf84
BigSimmo Jul 12, 2026
7154493
fix(rag): broaden compact-answer safety matcher to negation/withdrawa…
BigSimmo Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -498,9 +499,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,
Expand All @@ -519,7 +524,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 = isPreformattedGroundedAnswer(turn.answer);
const safeText = useMemo(
() => sanitizeAnswerDisplayText(turn.answer.answer, { preformatted: turnPreformatted }),
[turn.answer.answer, turnPreformatted],
);
const sourceCount =
renderModel.primarySources.length ||
turn.sources.length ||
Expand Down Expand Up @@ -558,7 +567,8 @@ function PriorAnswerTurnSurface({
) : (
<>
<NaturalLanguageAnswer
text={previewText}
text={turn.answer.answer}
preformatted={turnPreformatted}
sourceCount={sourceCount}
sourceOnly={turn.answer.answerQualityTier === "source_only"}
bestSource={renderModel.bestSource}
Expand Down Expand Up @@ -673,6 +683,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 = "",
Expand Down Expand Up @@ -2844,7 +2862,11 @@ export function ClinicalDashboard({
currentRelevance?.isSourceBacked !== false &&
answerRenderModel?.trust !== "unsupported";
const sourceLookup = useMemo(() => new Map(sources.map((source) => [source.id, source])), [sources]);
const safeAnswerText = useMemo(() => sanitizeAnswerDisplayText(answer?.answer ?? ""), [answer?.answer]);
const answerPreformatted = isPreformattedGroundedAnswer(answer);
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];
Expand All @@ -2859,7 +2881,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[] = [];
Expand All @@ -2880,7 +2906,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);
Expand Down Expand Up @@ -3613,7 +3639,6 @@ export function ClinicalDashboard({
<StagedAnswerResultSurface
answer={answer}
query={latestAnswerQuery ?? query}
safeAnswerText={safeAnswerText}
bestSource={bestSource}
sourceGovernanceWarnings={sourceGovernanceWarnings}
sourceSummary={sourceSummary}
Expand Down
149 changes: 135 additions & 14 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type {
AnswerSection,
AnswerSectionKind,
BestSourceRecommendation,
RagAnswer,
SearchResult,
SearchScopeSummary,
VisualEvidenceCard,
Expand Down Expand Up @@ -172,6 +173,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,
Expand Down Expand Up @@ -219,15 +226,87 @@ export function ScopeAndGovernanceNotice({
);
}

export function plainAnswerText(value: string) {
const useful = clinicalProseUsefulness(value);
return sanitizeAnswerDisplayText(useful.text || value, { minLength: 8, minTokens: 2 })
export type AnswerDisplayTextOptions = {
// Server-`preformatted` answers are display-ready by construction; skip the
// noise-stripping prose sanitizer and fragment slicing so their document
// names / facility codes survive.
preformatted?: boolean;
// Keep server high-yield bold (**…**) so <SafeBoldText> can render it.
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<RagAnswer, "preformatted" | "grounded"> | 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.
// Covers the common withhold / withdrawal / contraindication / negation /
// escalation directives so a short safety caveat is never dropped from the
// compact primary answer. Kept deliberately broad (matching a non-safety
// fragment only preserves it verbatim — the safe direction).
const primaryAnswerSafetySignalPattern =
/\b(?:withhold|withheld|stop|cease|discontinue\w*|suspend\w*|hold|held|threshold|escalat\w*|urgent|immediately|never|avoid|contraindicat\w*|toxic|red\s*zone|amber|(?:do|must|should|will)\s+not|not\s+recommended)\b/i;

// Test against a de-bolded copy so server bold markers inside a phrase
// ("do **not** administer", "red **zone**") on the preserveBold path can never
// defeat the safety match and let a caveat be dropped by the compact cap.
function isPrimaryAnswerSafetyFragment(fragment: string) {
return primaryAnswerSafetySignalPattern.test(fragment.replace(/\*\*/g, ""));
}

// 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();
}

function primaryAnswerDisplayText(value: string) {
const cleaned = plainAnswerText(value);
/**
* 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.
const base = options.preformatted ? value : clinicalProseUsefulness(value).text || value;
return sanitizeAndStripSyntheticNotice(base, options);
}

/**
* 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
*/
export function primaryAnswerDisplayText(value: string, options: AnswerDisplayTextOptions = {}) {
// 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 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 = sanitizeAndStripSyntheticNotice(value, { preformatted: false, preserveBold: options.preserveBold });
const fragments = cleaned
.split(/\r?\n+/)
.flatMap((line: string) =>
Expand All @@ -242,20 +321,44 @@ function primaryAnswerDisplayText(value: string) {
)
.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) =>
isPrimaryAnswerSafetyFragment(fragment) ? fragment : clinicalProseUsefulness(fragment).text || fragment,
)
.filter((fragment: string) => {
if (!fragment) return false;
if (isPrimaryAnswerSafetyFragment(fragment)) return true;
const useful = clinicalProseUsefulness(fragment);
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*$/, "")}...`;
const selected: string[] = [];
let nonSafetyKept = 0;
let wordBudget = 85;
for (const fragment of uniqueFragments) {
if (isPrimaryAnswerSafetyFragment(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*$/, "")}...`,
);
wordBudget = 0;
}
}
return selected.join(" ") || cleaned;
}

// One compact "Sources" pill in every state: the amber Source-only pill and the
Expand Down Expand Up @@ -537,8 +640,23 @@ 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,
sourceCount,
sourceOnly,
bestSource,
Expand All @@ -547,7 +665,10 @@ export function NaturalLanguageAnswer({
copied,
onCopy,
}: {
// Raw answer text (server bold intact); this component owns display
// sanitization so <SafeBoldText> can render the high-yield emphasis.
text: string;
preformatted?: boolean;
sourceCount: number;
sourceOnly: boolean;
bestSource: BestSourceRecommendation | null;
Expand All @@ -567,7 +688,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);
Expand Down
18 changes: 13 additions & 5 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"use client";
"use client";

import Link from "next/link";
import { memo, type RefObject, useCallback, useEffect, useRef, useState } from "react";
Expand All @@ -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,
Expand Down Expand Up @@ -38,10 +42,14 @@ 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,
safeAnswerText,
bestSource,
sourceGovernanceWarnings,
sourceSummary,
Expand All @@ -68,7 +76,6 @@ function StagedAnswerResultSurfaceImpl({
}: {
answer: RagAnswer;
query: string;
safeAnswerText: string;
bestSource: BestSourceRecommendation | null;
sourceGovernanceWarnings: SourceGovernanceWarning[];
sourceSummary?: EvidenceSummary;
Expand Down Expand Up @@ -211,7 +218,8 @@ function StagedAnswerResultSurfaceImpl({
>
<div className="min-w-0 space-y-3">
<NaturalLanguageAnswer
text={safeAnswerText || answer.answer}
text={answer.answer}
preformatted={isPreformattedGroundedAnswer(answer)}
sourceCount={sourceCount}
sourceOnly={answer.answerQualityTier === "source_only"}
bestSource={bestSource}
Expand Down
Loading