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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 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 @@ -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],
Expand Down Expand Up @@ -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],
Expand Down
46 changes: 30 additions & 16 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 @@ -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<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.
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.
*
Expand All @@ -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);
}

/**
Expand All @@ -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) =>
Expand Down
8 changes: 6 additions & 2 deletions src/components/clinical-dashboard/answer-result-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 @@ -215,7 +219,7 @@ function StagedAnswerResultSurfaceImpl({
<div className="min-w-0 space-y-3">
<NaturalLanguageAnswer
text={answer.answer}
preformatted={Boolean(answer.preformatted && answer.grounded)}
preformatted={isPreformattedGroundedAnswer(answer)}
sourceCount={sourceCount}
sourceOnly={answer.answerQualityTier === "source_only"}
bestSource={bestSource}
Expand Down
5 changes: 2 additions & 3 deletions src/components/clinical-dashboard/evidence-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { type AnswerFeedbackType } from "@/lib/answer-feedback";
import { ClinicalOutputPanel } from "@/components/clinical-dashboard/output-panel";
import {
isPreformattedGroundedAnswer,
keyClinicalItemsFromSections,
keyClinicalItemsFromTable,
plainAnswerText,
Expand Down Expand Up @@ -579,9 +580,7 @@ function clinicalNotesAvailableTabs(sections: ClinicalDetailSection[]) {
function clinicalNotesDetailSectionsForAnswer(answer: RagAnswer, viewMode: AnswerViewMode) {
const sections =
viewMode === "high_yield" ? buildHighYieldClinicalOutputSections(answer) : buildClinicalOutputSections(answer);
const primaryAnswer = plainAnswerText(answer.answer, {
preformatted: Boolean(answer.preformatted && answer.grounded),
});
const primaryAnswer = plainAnswerText(answer.answer, { preformatted: isPreformattedGroundedAnswer(answer) });
const keepVerifySource = answer.answerQualityTier === "source_only" || answer.grounded === false;
return sortClinicalDetailSections(
sections
Expand Down
6 changes: 2 additions & 4 deletions src/components/clinical-dashboard/output-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CheckCircle2, ListChecks } from "lucide-react";

import { AccessibleTable } from "@/components/AccessibleTable";
import { SafeBoldText } from "@/components/SafeBoldText";
import { plainAnswerText } from "@/components/clinical-dashboard/answer-content";
import { isPreformattedGroundedAnswer, plainAnswerText } from "@/components/clinical-dashboard/answer-content";
import { SectionHeading, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell";
import {
AnswerViewModeControl,
Expand Down Expand Up @@ -56,9 +56,7 @@ 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, {
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"))
Expand Down
16 changes: 8 additions & 8 deletions src/lib/rag-answer-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, " ")
Expand Down Expand Up @@ -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.
Expand Down