From 88cd5914aada6cafc4a8702cabbbe46db0b15ca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 15:29:51 +0000 Subject: [PATCH] refactor(rag): extract evidence-gate predicates into rag-evidence-gates (maturity X3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the rag.ts monolith decomposition (X3), enabled by X2's src/lib/rag/ directory. Moves 14 pure query/evidence gate predicates — the document-alias / admission title-support checks and the dose/route/frequency/risk-flowchart/source-image evidence gates — into a new src/lib/rag/rag-evidence-gates.ts. Behaviour-preserving: the function bodies are byte-identical to their previous rag.ts definitions. rag.ts imports the 13 it still calls (normalizeDocumentAliasText stays module-internal, used only by the alias helpers) and drops the sibling imports those predicates alone consumed. Ratchets the rag.ts maintainability budget 5238 -> 5030 to lock in the reduction (now 5018 lines), directly addressing the audit's "caps but never reduces" note. Verified: typecheck, lint (0 warnings), full test suite (3012 pass; the only failure is the pre-existing container-only pdf-extraction-budget flake, unrelated to rag), maintainability budgets, docs index/links, and prettier all green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Jc1ZYHFjXjn6mE6U6riVU --- scripts/check-maintainability-budgets.mjs | 2 +- src/lib/rag/rag-evidence-gates.ts | 154 +++++++++++++++++++++ src/lib/rag/rag.ts | 161 +++------------------- 3 files changed, 171 insertions(+), 146 deletions(-) create mode 100644 src/lib/rag/rag-evidence-gates.ts diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index bbf9dbe5d..69b348098 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; const budgets = new Map([ ["src/components/ClinicalDashboard.tsx", 4272], - ["src/lib/rag/rag.ts", 5238], + ["src/lib/rag/rag.ts", 5030], ["src/components/DocumentViewer.tsx", 3166], ["supabase/functions/indexing-v3-agent/index.ts", 2191], ]); diff --git a/src/lib/rag/rag-evidence-gates.ts b/src/lib/rag/rag-evidence-gates.ts new file mode 100644 index 000000000..2e0258bdf --- /dev/null +++ b/src/lib/rag/rag-evidence-gates.ts @@ -0,0 +1,154 @@ +import type { SearchResult } from "@/lib/types"; +import { + analyzeClinicalQuery, + riskZoneActionPattern, + riskZoneContextPattern, + zoneContextPatternsForQuery, +} from "@/lib/clinical-search"; +import { isClinicalImageEvidence } from "@/lib/image-filtering"; +import { evidenceTextForGate } from "@/lib/rag/rag-answer-support"; +import { hasDirectTitleSupport } from "@/lib/rag/rag-routing"; + +// Extracted from rag.ts (maturity X3): pure query/evidence gate predicates that +// decide fast-path eligibility and evidence sufficiency. Behaviour-preserving — +// the function bodies are byte-identical to their previous rag.ts definitions. + +/** Normalize document alias text. */ +function normalizeDocumentAliasText(value: string) { + return value + .replace(/([a-z])([A-Z])/g, "$1 $2") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** Has document alias without top title support. */ +export function hasDocumentAliasWithoutTopTitleSupport(query: string, results: SearchResult[]) { + const aliases = analyzeClinicalQuery(query) + .documentTitleTerms.map(normalizeDocumentAliasText) + .filter((term) => term.length > 3); + if (!aliases.length) return false; + + return !results.slice(0, 5).some((result) => { + if (result.match_explanation?.titleHit || result.match_explanation?.labelHit) return true; + const title = normalizeDocumentAliasText(`${result.title} ${result.file_name}`); + return aliases.some((alias) => title.includes(alias)); + }); +} + +/** Has admission community lookup intent. */ +export function hasAdmissionCommunityLookupIntent(query: string) { + const normalized = normalizeDocumentAliasText(query); + return /\badmission\b/.test(normalized) && /\bcommunity\b/.test(normalized); +} + +/** Has admission community title support. */ +export function hasAdmissionCommunityTitleSupport(results: SearchResult[]) { + return results.slice(0, 5).some((result) => { + if (result.match_explanation?.titleHit || result.match_explanation?.labelHit) { + const title = normalizeDocumentAliasText(`${result.title} ${result.file_name}`); + return /\badmission\b/.test(title) && /\bcommunity\b/.test(title); + } + const title = normalizeDocumentAliasText(`${result.title} ${result.file_name}`); + return ( + title.includes("admission of community patient") || + title.includes("admission community pt") || + title.includes("admission to discharge for community") + ); + }); +} + +/** Top evidence text. */ +export function topEvidenceText(results: SearchResult[], limit = 5) { + return results.slice(0, limit).map(evidenceTextForGate).join(" "); +} + +/** Has any term. */ +export function hasAnyTerm(text: string, pattern: RegExp) { + return pattern.test(text); +} + +/** Is risk flowchart next step query. */ +export function isRiskFlowchartNextStepQuery(query: string) { + return ( + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && + riskZoneContextPattern.test(query) && + /\b(?:next step|step after|after|action)\b/i.test(query) + ); +} + +/** Has risk flowchart action evidence. */ +export function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], limit = 5) { + // A single result must carry BOTH the zone context and the action language + // (escalate / urgent review): scattering the two term groups across different + // results (or their image captions) let unrelated risk-assessment flowcharts + // pass. Deliberately does NOT require a flowchart word in the evidence — the + // escalation protocols that answer a red-zone question express the flowchart's + // decision steps as prose ("has any Purple or Red Zone criteria ... escalate + // for Senior Clinician Review") without ever saying "flowchart". + // + // The shared patterns are scoped to the colour the query names (a red-zone + // question must not fast-path on an amber-zone chunk); for risk-matrix / + // flowchart visual units the bare cell colour token counts as zone context. + const { zonePhrasePattern, bareColourPattern } = zoneContextPatternsForQuery(query); + return results.slice(0, limit).some((result) => { + const evidenceText = evidenceTextForGate(result); + if (!riskZoneActionPattern.test(evidenceText)) return false; + if (zonePhrasePattern.test(evidenceText)) return true; + return ( + ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && + bareColourPattern.test(evidenceText) + ); + }); +} + +/** Has dose amount evidence for gate. */ +export function hasDoseAmountEvidenceForGate(result: SearchResult) { + return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|micrograms?|milligrams?|ug|[µμ]g)\b/i.test(evidenceTextForGate(result)); +} + +/** Has route evidence for gate. */ +export function hasRouteEvidenceForGate(result: SearchResult) { + return /\b(?:oral|orally|intramuscular|intramuscularly|subcutaneous|subcutaneously|subcut|sublingual|sublingually|\bim\b|\bpo\b|\bsc\b|\bsl\b)\b/i.test( + evidenceTextForGate(result), + ); +} + +/** Has administration frequency evidence for gate. */ +export function hasFrequencyEvidenceForGate(result: SearchResult) { + return /\b(?:once|twice|daily|nightly|weekly|monthly|hourly|prn|bd|tds|qds|qid|every\s+\d+(?:\.\d+)?\s*(?:hours?|days?|weeks?)|\d+\s+times?\s+(?:a|per)\s+(?:day|week|hour))\b/i.test( + evidenceTextForGate(result), + ); +} + +/** Has direct source image evidence. */ +export function hasDirectSourceImageEvidence(result: SearchResult) { + const sourceImageIds = new Set( + [result.index_unit?.source_image_id, ...(result.table_facts ?? []).map((fact) => fact.source_image_id)].filter( + Boolean, + ) as string[], + ); + return ( + sourceImageIds.size > 0 || + (result.images ?? []).some( + (image) => sourceImageIds.has(image.id) || isClinicalImageEvidence(image) || image.source_kind === "table_crop", + ) + ); +} + +/** Source image required for query. */ +export function sourceImageRequiredForQuery(query: string) { + return ( + /\b(?:show|display|attach|open|view|source|original)\b/i.test(query) && + /\b(?:image|table|chart|figure|crop|visual)\b/i.test(query) + ); +} + +/** Direct title or alias support. */ +export function directTitleOrAliasSupport(query: string, results: SearchResult[]) { + return ( + hasDirectTitleSupport(query, results) || + results.slice(0, 5).some((result) => result.match_explanation?.titleHit || result.match_explanation?.labelHit) + ); +} diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index 73f0a1a36..6425d2439 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -141,9 +141,6 @@ import { medicationDoseQueryContext, normalizedClinicalSearchTokens, rankClinicalResults, - riskZoneActionPattern, - riskZoneContextPattern, - zoneContextPatternsForQuery, } from "@/lib/clinical-search"; import { env, requestedOpenAIAnswerModels } from "@/lib/env"; import { @@ -159,7 +156,7 @@ import { } from "@/lib/query-privacy"; import { normalizeSourceMetadata } from "@/lib/source-metadata"; import { safeErrorLogDetails } from "@/lib/privacy"; -import { isClinicalImageEvidence, normalizeImageBbox } from "@/lib/image-filtering"; +import { normalizeImageBbox } from "@/lib/image-filtering"; import { SOURCE_BACKED_REVIEW_FALLBACK_REASON, chooseAnswerRoute, @@ -180,7 +177,6 @@ import { buildIndexingQuality, collectMemoryCards, deriveConfidence, - evidenceTextForGate, fallbackReasonFromRouting, isProviderGenerationDegraded, machineReadableFallbackAnswer, @@ -206,6 +202,21 @@ import { unavailableDocumentNoisePattern, } from "@/lib/rag/rag-query-guard"; export { shouldShortCircuitUnsupportedSearch } from "@/lib/rag/rag-query-guard"; +import { + directTitleOrAliasSupport, + hasAdmissionCommunityLookupIntent, + hasAdmissionCommunityTitleSupport, + hasAnyTerm, + hasDirectSourceImageEvidence, + hasDocumentAliasWithoutTopTitleSupport, + hasDoseAmountEvidenceForGate, + hasFrequencyEvidenceForGate, + hasRiskFlowchartActionEvidence, + hasRouteEvidenceForGate, + isRiskFlowchartNextStepQuery, + sourceImageRequiredForQuery, + topEvidenceText, +} from "@/lib/rag/rag-evidence-gates"; import { cleanClinicalSummaryText, isLowYieldClinicalText } from "@/lib/source-text-sanitizer"; import { hasClinicalAnswerQualityIssue, @@ -1885,52 +1896,6 @@ export function decideTextFastPath( return { returnFastPath: false, reason: "weak_text_match" }; } -/** Normalize document alias text. */ -function normalizeDocumentAliasText(value: string) { - return value - .replace(/([a-z])([A-Z])/g, "$1 $2") - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -/** Has document alias without top title support. */ -function hasDocumentAliasWithoutTopTitleSupport(query: string, results: SearchResult[]) { - const aliases = analyzeClinicalQuery(query) - .documentTitleTerms.map(normalizeDocumentAliasText) - .filter((term) => term.length > 3); - if (!aliases.length) return false; - - return !results.slice(0, 5).some((result) => { - if (result.match_explanation?.titleHit || result.match_explanation?.labelHit) return true; - const title = normalizeDocumentAliasText(`${result.title} ${result.file_name}`); - return aliases.some((alias) => title.includes(alias)); - }); -} - -/** Has admission community lookup intent. */ -function hasAdmissionCommunityLookupIntent(query: string) { - const normalized = normalizeDocumentAliasText(query); - return /\badmission\b/.test(normalized) && /\bcommunity\b/.test(normalized); -} - -/** Has admission community title support. */ -function hasAdmissionCommunityTitleSupport(results: SearchResult[]) { - return results.slice(0, 5).some((result) => { - if (result.match_explanation?.titleHit || result.match_explanation?.labelHit) { - const title = normalizeDocumentAliasText(`${result.title} ${result.file_name}`); - return /\badmission\b/.test(title) && /\bcommunity\b/.test(title); - } - const title = normalizeDocumentAliasText(`${result.title} ${result.file_name}`); - return ( - title.includes("admission of community patient") || - title.includes("admission community pt") || - title.includes("admission to discharge for community") - ); - }); -} - /** Should return before memory. */ function shouldReturnBeforeMemory( queryClass: RagQueryClass, @@ -1943,100 +1908,6 @@ function shouldReturnBeforeMemory( return !shouldUseMemoryBeforeFastPath(queryClass); } -/** Top evidence text. */ -function topEvidenceText(results: SearchResult[], limit = 5) { - return results.slice(0, limit).map(evidenceTextForGate).join(" "); -} - -/** Has any term. */ -function hasAnyTerm(text: string, pattern: RegExp) { - return pattern.test(text); -} - -/** Is risk flowchart next step query. */ -function isRiskFlowchartNextStepQuery(query: string) { - return ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && - riskZoneContextPattern.test(query) && - /\b(?:next step|step after|after|action)\b/i.test(query) - ); -} - -/** Has risk flowchart action evidence. */ -function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], limit = 5) { - // A single result must carry BOTH the zone context and the action language - // (escalate / urgent review): scattering the two term groups across different - // results (or their image captions) let unrelated risk-assessment flowcharts - // pass. Deliberately does NOT require a flowchart word in the evidence — the - // escalation protocols that answer a red-zone question express the flowchart's - // decision steps as prose ("has any Purple or Red Zone criteria ... escalate - // for Senior Clinician Review") without ever saying "flowchart". - // - // The shared patterns are scoped to the colour the query names (a red-zone - // question must not fast-path on an amber-zone chunk); for risk-matrix / - // flowchart visual units the bare cell colour token counts as zone context. - const { zonePhrasePattern, bareColourPattern } = zoneContextPatternsForQuery(query); - return results.slice(0, limit).some((result) => { - const evidenceText = evidenceTextForGate(result); - if (!riskZoneActionPattern.test(evidenceText)) return false; - if (zonePhrasePattern.test(evidenceText)) return true; - return ( - ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && - bareColourPattern.test(evidenceText) - ); - }); -} - -/** Has dose amount evidence for gate. */ -function hasDoseAmountEvidenceForGate(result: SearchResult) { - return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|micrograms?|milligrams?|ug|[µμ]g)\b/i.test(evidenceTextForGate(result)); -} - -/** Has route evidence for gate. */ -function hasRouteEvidenceForGate(result: SearchResult) { - return /\b(?:oral|orally|intramuscular|intramuscularly|subcutaneous|subcutaneously|subcut|sublingual|sublingually|\bim\b|\bpo\b|\bsc\b|\bsl\b)\b/i.test( - evidenceTextForGate(result), - ); -} - -/** Has administration frequency evidence for gate. */ -function hasFrequencyEvidenceForGate(result: SearchResult) { - return /\b(?:once|twice|daily|nightly|weekly|monthly|hourly|prn|bd|tds|qds|qid|every\s+\d+(?:\.\d+)?\s*(?:hours?|days?|weeks?)|\d+\s+times?\s+(?:a|per)\s+(?:day|week|hour))\b/i.test( - evidenceTextForGate(result), - ); -} - -/** Has direct source image evidence. */ -function hasDirectSourceImageEvidence(result: SearchResult) { - const sourceImageIds = new Set( - [result.index_unit?.source_image_id, ...(result.table_facts ?? []).map((fact) => fact.source_image_id)].filter( - Boolean, - ) as string[], - ); - return ( - sourceImageIds.size > 0 || - (result.images ?? []).some( - (image) => sourceImageIds.has(image.id) || isClinicalImageEvidence(image) || image.source_kind === "table_crop", - ) - ); -} - -/** Source image required for query. */ -function sourceImageRequiredForQuery(query: string) { - return ( - /\b(?:show|display|attach|open|view|source|original)\b/i.test(query) && - /\b(?:image|table|chart|figure|crop|visual)\b/i.test(query) - ); -} - -/** Direct title or alias support. */ -function directTitleOrAliasSupport(query: string, results: SearchResult[]) { - return ( - hasDirectTitleSupport(query, results) || - results.slice(0, 5).some((result) => result.match_explanation?.titleHit || result.match_explanation?.labelHit) - ); -} - /** Record retrieval selection telemetry. */ function recordRetrievalSelectionTelemetry( telemetry: SearchTelemetry,