Skip to content
6 changes: 6 additions & 0 deletions docs/process-hardening.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -197,6 +197,12 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went
- **Verification debt:** `npm run verify:release` (and its governance/eval gates) has not been run for this workstream — the authoring environment has no live Supabase/OpenAI keys. Run it from a secrets-equipped environment after merge; the cross-mode surface itself is additive/navigational, so `verify:cheap` + `verify:ui` are the load-bearing local gates.
- **Telemetry note:** cross-mode clicks write `rag_query_misses` rows with `clicked_document_id: null` and the target mode/slug in `metadata`; retrieval-quality reviews that aggregate misses by document should filter on `metadata.interaction`.

## rag.ts decomposition — part 1 (2026-07-06)

- **Shipped on `claude/rag-decomp` (moves 1–4 of the approved 8-move sequence, all verbatim move-only):** quote-verification family → `rag-quote-verification.ts`; source-block family (`buildRagSourceBlock`, `truncateForModel`, table formatters) → `rag-source-block.ts`; numeric-verification application (`applyNumericVerification`, `unboldUnverifiedNumbers`) → `answer-verification.ts` beside the primitives it wraps; model-context selection (`capPerDocumentCrowding`, `selectModelContextResults`) → `rag-context-selection.ts`. Four tiny shared helpers re-homed to domain siblings (`allowedChunkMap` → citations, `safeRecord`/`metadataText` → rag-answer-text, `appendRoutingReason` → rag-routing); rag.ts's `resultCitation` was an exact duplicate of `citations.citationFromResult` and now aliases it. All extractions are cycle-free (modules import one-way from siblings; rag.ts re-exports moved names its tests/consumers import). rag.ts 7,874 → ~7,450 lines.
- **Measured coupling for the remaining moves (do not trust the pre-drift map):** the extractive-answer family (L, ~1,085 lines) and the answer-quality/reasoning-effort family (M, ~465 lines) are **mutually entangled** on main (M calls `classifyAnswerIntent`/`boldHighYieldClinicalText` from L; L calls `finalQualityGapAnswer`/`isFragmentLikeClinicalAnswer`/`hasBadFinalAnswerQuality` from M) and both reach into the coverage-gate helpers (K) — extract L+M together in a dedicated pass, or accept module→rag.ts back-edges. Retrieval variants (H) additionally depend on owner-scope helpers (`assertGlobalSearchAllowed`, `ownerScopeForDocumentFilteredRetrieval`) and live alias fetching; context packing (J) depends on `stableHash` + committed-generation helpers and belongs with the cache region (F) move.
- **Standing gate:** any continuation that touches H/F (retrieval-side) must pass `npm run eval:retrieval:quality` (23/23) before merge, per the golden-eval rule above.

## Answer-thread Back button: URL and visible answer can disagree (2026-07-06)

- **Behaviour:** inside an Answer thread, browser Back changes the URL (`/?mode=answer&q=A&run=1` ← `...q=B&run=1`) but the rendered answer/thread does not change. Two guards produce this: the auto-run effect skips when `run=1` is already present, and the answer view early-returns when an answer is already on screen (`ClinicalDashboard`). This is thread persistence by design, not an accident — clearing the thread on Back would destroy in-progress clinical context.
Expand Down
118 changes: 117 additions & 1 deletion src/lib/answer-verification.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
import type { Citation, DocumentTableFact, SearchResult } from "@/lib/types";
import { appendRoutingReason } from "@/lib/rag-routing";
import type {
AnswerSectionKind,
Citation,
ConflictOrGap,
DocumentTableFact,
RagAnswer,
SearchResult,
} from "@/lib/types";

// GEN-C2 / GEN-H2 — shared numeric faithfulness verification.
//
Expand DownExpand Up@@ -194,3 +202,111 @@ function sourceNumericTokenSet(results: SearchResult[]): Set<string> {

export const VERIFY_AGAINST_SOURCE_NOTE =
"CRITICAL: Some figures in this answer could not be matched verbatim to the cited sources — verify against the source documents before acting.";

// GEN-C2 / GEN-H2: verify every numeric/dose/threshold token in the generated
// answer against the text of its cited chunks. Unsupported figures are recorded
// on the answer and an explicit "verify against source" caveat is appended so a
// paraphrased/mis-transcribed dose can never read as authoritative.
const actionableNumericAnswerPattern =
/\b(?:dose|dosage|dosing|mg|mcg|microgram|micrograms|route|oral|intramuscular|\bim\b|\bpo\b|frequency|daily|twice|weekly|monthly|hourly|threshold|cutoff|cut-off|anc|fbc|wbc|withhold|cease|stop|discontinue|red\s+(?:result|range|zone)|amber\s+(?:result|range|zone)|green\s+(?:result|range|zone)|monitor|monitoring|interval|repeat|review|risk\s+score|risk|score|escalat|urgent)\b/i;

const actionableNumericSectionKinds = new Set<AnswerSectionKind>([
"medication_dose",
"thresholds",
"monitoring_timing",
"escalation_risk",
"required_actions",
]);

function hasActionableNumericContext(answer: RagAnswer) {
if (!answer.grounded || answer.confidence === "unsupported") return false;
if (answer.queryClass === "medication_dose_risk" || answer.queryClass === "table_threshold") return true;
if (
(answer.answerSections ?? []).some((section) => section.kind && actionableNumericSectionKinds.has(section.kind))
) {
return true;
}
const text = [
answer.answer,
answer.routingReason,
...(answer.answerSections ?? []).flatMap((section) => [section.heading, section.body]),
]
.filter(Boolean)
.join(" ");
return actionableNumericAnswerPattern.test(text);
}

export function applyNumericVerification(answer: RagAnswer): RagAnswer {
const sources = answer.sources ?? [];
const unverified = new Set<string>();

// B4: the model is instructed to put dose details in structured
// answerSections (kind medication_dose), so a top-level-only scan never sees
// section-body doses. Verify the top-level answer AND every section body.
// Each section is scoped to its own citation_chunk_ids when present, so a
// dose is only credited against the chunks that section actually cites;
// sections with no citations fall back to the answer-level citations.
const answerVerification = verifyAnswerNumbers(answer.answer, answer.citations, sources);
for (const token of answerVerification.unverifiedTokens) unverified.add(token);

for (const section of answer.answerSections ?? []) {
const sectionCitations =
section.citation_chunk_ids.length > 0
? section.citation_chunk_ids.map((chunk_id) => ({ chunk_id }))
: answer.citations;
const sectionVerification = verifyAnswerNumbers(section.body, sectionCitations, sources);
for (const token of sectionVerification.unverifiedTokens) unverified.add(token);
}

if (unverified.size === 0) return answer;

const unverifiedTokens = [...unverified];
answer.unverifiedNumericTokens = unverifiedTokens;
answer.faithfulnessWarning = VERIFY_AGAINST_SOURCE_NOTE;
// P8: never bold a figure the system could not verify against the cited sources — bold emphasis
// must track verification, or an unverified dose/threshold reads as authoritative while its caveat
// sits in a separate block. Un-wrap **…** only around segments carrying an unverified token.
answer.answer = unboldUnverifiedNumbers(answer.answer, unverified);
if (answer.answerSections?.length) {
answer.answerSections = answer.answerSections.map((section) => ({
...section,
body: unboldUnverifiedNumbers(section.body, unverified),
}));
}
// Surface as a source gap so the UI's existing gap rendering shows it, and
// never let an answer with unverified clinical numbers claim high confidence.
// This gate runs more than once on the model path (parse-time and finalize-time), so REPLACE any
// earlier faithfulness caveat rather than appending a duplicate "CRITICAL…" gap; the latest run
// carries the freshest token list.
const caveat: ConflictOrGap = {
type: "gap",
message: `${VERIFY_AGAINST_SOURCE_NOTE} Unverified figures: ${unverifiedTokens.join(", ")}.`,
};
answer.conflictsOrGaps = [
...(answer.conflictsOrGaps ?? []).filter((gap) => !gap.message.startsWith(VERIFY_AGAINST_SOURCE_NOTE)),
caveat,
];
if (hasActionableNumericContext(answer)) {
answer.answer =
"I found source material, but the generated answer included clinical numbers that could not be matched verbatim to its cited source chunks. Review the source passages directly before using this for dose, threshold, route, timing, monitoring, or risk decisions.";
answer.grounded = false;
answer.confidence = "unsupported";
answer.responseMode = "evidence_gap";
answer.answerSections = [];
answer.citations = [];
answer.quoteCards = [];
answer.routingReason = appendRoutingReason(answer.routingReason, "numeric_faithfulness_gate_source_gap");
return answer;
}
if (answer.confidence === "high") answer.confidence = "medium";
return answer;
}

// Remove bold emphasis around any **…** segment that contains a numeric token the source-numeric
// verification could not confirm, leaving the text intact (just un-emphasised). Verified bold stays.
export function unboldUnverifiedNumbers(text: string, unverified: Set<string>): string {
if (!unverified.size || !text.includes("**")) return text;
return text.replace(/\*\*([^*]+)\*\*/g, (full, inner: string) =>
extractNumericTokens(inner).some((token) => unverified.has(token)) ? inner : full,
);
}
4 changes: 4 additions & 0 deletions src/lib/citations.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,3 +115,7 @@ export function compactCitations(results: SearchResult[], limit = 6) {

return citations;
}

export function allowedChunkMap(results: SearchResult[]) {
return new Map(results.map((result) => [result.id, result]));
}
9 changes: 9 additions & 0 deletions src/lib/rag-answer-text.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,3 +258,12 @@ export function hasClinicalAnswerQualityIssue(value: string) {
export function isUsableAnswerSectionText(value: string, options: { minTokens?: number; minLength?: number } = {}) {
return Boolean(sanitizeStructuredText(value, options));
}

export function safeRecord(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}

export function metadataText(metadata: Record<string, unknown>, key: string) {
const value = metadata[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
43 changes: 43 additions & 0 deletions src/lib/rag-context-selection.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
import type { RagAnswer, RagQueryClass, SearchResult } from "@/lib/types";

const fastRoutineModelContextLimit = 4;

const maxContextChunksPerDocument = 3;

// P9: keep one verbose document from dominating the sources the model sees. Cap each document to at
// most `maxContextChunksPerDocument` chunks (order-preserving, no reranking/dedup), but only when the
// result set spans multiple documents — a genuinely single-document answer must not be starved.
export function capPerDocumentCrowding(results: SearchResult[], maxPerDocument = maxContextChunksPerDocument) {
if (results.length <= maxPerDocument) return results;
const distinctDocuments = new Set(results.map((result) => result.document_id)).size;
if (distinctDocuments < 2) return results;
const documentCounts = new Map<string, number>();
const capped: SearchResult[] = [];
for (const result of results) {
const count = documentCounts.get(result.document_id) ?? 0;
if (count >= maxPerDocument) continue;
documentCounts.set(result.document_id, count + 1);
capped.push(result);
}
return capped;
}

export function selectModelContextResults(args: {
routeMode: RagAnswer["routingMode"];
queryClass: RagQueryClass;
crossDocument: boolean;
results: SearchResult[];
}) {
const results = capPerDocumentCrowding(args.results);
if (args.routeMode !== "fast") return results;
if (
args.crossDocument ||
args.queryClass === "comparison" ||
args.queryClass === "broad_summary" ||
args.queryClass === "medication_dose_risk" ||
args.queryClass === "table_threshold"
) {
return results;
}
return results.slice(0, fastRoutineModelContextLimit);
}
113 changes: 113 additions & 0 deletions src/lib/rag-quote-verification.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
import { allowedChunkMap, citationFromResult as resultCitation, compactCitations } from "@/lib/citations";
import { safeRecord, sanitizeStructuredText } from "@/lib/rag-answer-text";
import { appendRoutingReason } from "@/lib/rag-routing";
import { sourceTextForClinicalProse } from "@/lib/source-text-sanitizer";
import type { ConflictOrGap, QuoteCard, RagAnswer, SearchResult } from "@/lib/types";

export function normalizeQuoteVerificationText(text: string) {
return sourceTextForClinicalProse(text)
.normalize("NFKC")
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}

export function tableFactQuoteText(fact: NonNullable<SearchResult["table_facts"]>[number]) {
// Mirrors tableFactText in answer-verification.ts: include the fact-metadata
// snippet fields that rich-mode prompts show the model (tableSnippetForFact),
// so quotes drawn from them verify as exact.
const metadata = safeRecord(fact.metadata);
const metadataString = (key: string) => (typeof metadata[key] === "string" ? (metadata[key] as string) : "");
const metadataCells = Array.isArray(metadata.cells) ? (metadata.cells as unknown[]).map(String).join(" ") : "";
return [
fact.table_title,
fact.row_label,
fact.clinical_parameter,
fact.threshold_value,
fact.action,
metadataString("accessible_table_markdown"),
metadataString("table_text_snippet"),
metadataCells,
]
.filter(Boolean)
.join(" ");
}

export function sourceTextForQuoteVerification(source: SearchResult) {
const parts = [
source.content,
source.adjacent_context,
source.section_heading,
source.retrieval_synopsis,
source.table_facts?.map(tableFactQuoteText).join(" "),
source.memory_cards?.map((card) => card.content).join(" "),
source.index_unit ? [source.index_unit.title, source.index_unit.content].filter(Boolean).join(" ") : "",
source.images
?.map((image) =>
[image.tableLabel, image.tableTitle, image.caption, image.tableTextSnippet, image.accessibleTableMarkdown]
.filter(Boolean)
.join(" "),
)
.join(" "),
];
return parts.filter(Boolean).join(" ");
}

export function isExactSourceQuote(quote: string, source: SearchResult) {
const normalizedQuote = normalizeQuoteVerificationText(quote);
if (normalizedQuote.length < 8) return false;
const normalizedSource = normalizeQuoteVerificationText(sourceTextForQuoteVerification(source));
return normalizedSource.includes(normalizedQuote);
}

export function sanitizeQuoteCards(
cards: Array<{ chunk_id: string; quote: string; section_heading?: string | null }> | undefined,
results: SearchResult[],
): QuoteCard[] {
const chunks = allowedChunkMap(results);
return (cards ?? [])
.map((card) => {
const source = chunks.get(card.chunk_id);
if (!source) return null;
const quote = sanitizeStructuredText(card.quote, { minLength: 8, minTokens: 2 });
if (!quote) return null;
if (!isExactSourceQuote(quote, source)) return null;
return {
...resultCitation(source),
quote,
section_heading: card.section_heading ?? source.section_heading,
} satisfies QuoteCard;
})
.filter((card): card is QuoteCard => Boolean(card));
}

export function sanitizeConflictsOrGaps(items: ConflictOrGap[] | undefined, results: SearchResult[]): ConflictOrGap[] {
const allowed = new Set(results.map((result) => result.id));
return (items ?? [])
.map((item) => ({
type: item.type,
message: sanitizeStructuredText(item.message, { minLength: 8, minTokens: 2 }) || item.message,
source_chunk_ids: item.source_chunk_ids?.filter((id) => allowed.has(id)),
}))
.filter((item) => !item.source_chunk_ids || item.source_chunk_ids.length > 0);
}

export function enrichGroundedReviewCitations(answer: RagAnswer, results: SearchResult[], minCitations = 2): RagAnswer {
if (!answer.grounded || answer.confidence === "unsupported") return answer;
if (answer.citations.length >= minCitations) return answer;
if ((answer.unverifiedNumericTokens?.length ?? 0) > 0 || answer.faithfulnessWarning) return answer;

const existing = new Set(answer.citations.map((citation) => citation.chunk_id));
const additional = compactCitations(results)
.filter((citation) => !existing.has(citation.chunk_id))
.slice(0, minCitations - answer.citations.length);
if (additional.length === 0) return answer;

return {
...answer,
citations: [...answer.citations, ...additional],
routingReason: appendRoutingReason(answer.routingReason, "review_citations_enriched"),
};
}
4 changes: 4 additions & 0 deletions src/lib/rag-routing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -599,3 +599,7 @@ export function shouldRetryWithStrongAfterFast(args: {
if (args.route.reason === "clinical_fast_grounded_synthesis") return solidSourceSupport;
return solidSourceSupport && args.results.length >= 2;
}

export function appendRoutingReason(reason: string | undefined, addition: string) {
return reason ? `${reason}; ${addition}` : addition;
}
Loading
Loading