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
18 changes: 15 additions & 3 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = "",
Expand Down
34 changes: 34 additions & 0 deletions src/components/clinical-dashboard/answer-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion 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 Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/components/clinical-dashboard/display-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, " ");
}
Expand Down Expand Up @@ -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<SearchResult["table_facts"]>[number]) {
const fields = [
{ label: "Table", value: fact.table_title },
Expand All @@ -208,6 +220,12 @@ export function compactTableFact(fact: NonNullable<SearchResult["table_facts"]>[
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
Expand Down
7 changes: 7 additions & 0 deletions src/components/clinical-dashboard/evidence-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/components/clinical-dashboard/output-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions src/lib/answer-render-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down
15 changes: 14 additions & 1 deletion src/lib/rag-answer-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 26 additions & 4 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createAdminClient>;
queryVariants: string[];
Expand Down Expand Up @@ -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<typeof createAdminClient>;
query: string;
Expand Down Expand Up @@ -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<typeof createAdminClient>;
query: string;
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion src/lib/source-text-sanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
}
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down