From 042cec6470b6e56f1d88bf48d78b8eebc5fca925 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:00:02 +0800 Subject: [PATCH 1/6] refactor(rag): extract extractive-answer + answer-quality family to rag-extractive-answer.ts (move-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 1 of rag.ts decomposition part 2. The extractive-answer family (L) and the answer-quality/reasoning-effort family (M) are mutually entangled on main, so per the part-1 coupling notes they move together as one module — keeping the L<->M entanglement internal (no cross-module cycle). Lines 3954-5503 moved verbatim (byte-identical modulo the `export` keyword added to the 9 functions that now cross the module boundary; verified empty block diff otherwise). rag.ts imports the 12 names it still calls and re-exports the 6 that tests import. Per the part-1-sanctioned "accept back-edges" option, the module imports 9 call-time helpers back from rag.ts (evidenceTextForGate, rankMemoryCardsForAnswer, deriveConfidence, scoreValue, machineReadableFallbackAnswer, buildAnswerScoreExplanations, buildIndexingQuality, collectMemoryCards, fallbackReasonFromRouting) — 6 gained an `export` keyword. The aliased citations import (citationFromResult as resultCitation) travels with the module; 5 imports orphaned by the move removed from rag.ts. rag.ts 7,550 -> 6,025 lines. Gate: tsc clean (no new errors vs baseline), focused vitest 75/75, lint (no new warnings), prettier clean. Co-Authored-By: Claude Opus 4.8 --- src/lib/rag-extractive-answer.ts | 1586 +++++++++++++++++++++++++++++ src/lib/rag.ts | 1598 +----------------------------- 2 files changed, 1618 insertions(+), 1566 deletions(-) create mode 100644 src/lib/rag-extractive-answer.ts diff --git a/src/lib/rag-extractive-answer.ts b/src/lib/rag-extractive-answer.ts new file mode 100644 index 0000000000..654ff4af82 --- /dev/null +++ b/src/lib/rag-extractive-answer.ts @@ -0,0 +1,1586 @@ +import { boldHighYieldClinicalText } from "@/lib/answer-ranking"; +import { applyNumericVerification } from "@/lib/answer-verification"; +import { citationFromResult as resultCitation, compactCitations } from "@/lib/citations"; +import { classifyRagQuery } from "@/lib/clinical-search"; +import { ragDeepMemoryVersion } from "@/lib/deep-memory"; +import { buildDocumentBreakdown, extractQuoteCards } from "@/lib/evidence"; +import type { OpenAIReasoningEffort } from "@/lib/openai"; +import { + buildAnswerScoreExplanations, + buildIndexingQuality, + collectMemoryCards, + deriveConfidence, + evidenceTextForGate, + fallbackReasonFromRouting, + machineReadableFallbackAnswer, + rankMemoryCardsForAnswer, + scoreValue, +} from "@/lib/rag"; +import { + hasClinicalAnswerQualityIssue, + looksLikeJsonArtifact, + normalizeSectionText, + sanitizeAnswerText, + splitBalancedWords, +} from "@/lib/rag-answer-text"; +import { ragProviderMode } from "@/lib/rag-provider"; +import { isLowYieldClinicalText, sourceTextForClinicalProse } from "@/lib/source-text-sanitizer"; +import type { + AnswerSection, + AnswerSectionKind, + ConflictOrGap, + QuoteCard, + RagAnswer, + RagQueryClass, + SearchResult, +} from "@/lib/types"; + +type AnswerIntent = + | "dose" + | "contraindication" + | "monitoring_schedule" + | "red_result_action" + | "document_lookup" + | "pathway_referral" + | "unsupported" + | "general"; + +type ExtractedClinicalFactKind = + | "bottom_line" + | "dose" + | "renal_limit" + | "monitoring" + | "threshold_action" + | "contraindication" + | "pathway_referral" + | "caveat"; + +type ExtractedClinicalFact = { + kind: ExtractedClinicalFactKind; + text: string; + citationChunkIds: string[]; + priority: number; +}; + +const extractiveLabelPattern = + /\b(?:Medication point|Table evidence|Threshold\/action|Risk\/escalation|Workflow step|Section summary|Source point|Dose detail|Monitoring)\s*:\s*/gi; + +function cleanExtractivePointText(value: string) { + return sourceTextForClinicalProse(value) + .replace(/\b(?:clinical_table|table_crop|diagram_crop)\b/gi, " ") + .replace( + /^(?:clinical\s+)?table\s+(?:showing|detailing|listing|outlining|describing)\b.*?:\s*(?=\b(?:if|when|for|cease|stop|withhold|contact|repeat|monitor|clozapine)\b)/i, + "", + ) + .replace( + /^[A-Z][A-Za-z /-]{3,80}:\s*(?=\b(?:if|when|for|cease|stop|withhold|contact|repeat|monitor|clozapine)\b)/, + "", + ) + .replace(extractiveLabelPattern, " ") + .replace(/^[\s\-•:]+/, "") + .replace(/^(?:monitoring|dose|dosing|source|section|table|guideline)\s*[.;:,-]\s*/i, "") + .replace(/([A-Za-z)])(\d{1,2})(?=(?:[,.;]|\s|$))/g, "$1") + .replace(/\s+[•]\s+/g, ". ") + .replace( + /\s+-\s+(?=[A-Z][a-z])|(?:\s+-\s*)?(?:Medication point|Table evidence|Threshold\/action|Risk\/escalation|Workflow step|Section summary|Source point)\s*:\s*/gi, + ". ", + ) + .replace(/\s+/g, " ") + .replace(/\s+([,.;:])/g, "$1") + .replace(/(?:\.\s*){2,}/g, ". ") + .trim(); +} + +const extractiveClinicalDirectivePattern = + /\b(?:arrange|assess|cease|check|complete|contact|continue|discontinue|discontinued|escalate|notify|prescribe|record|refer|report|review|stop|withhold|must|required|requires?|should)\b/i; +const extractiveQueryStopwords = new Set([ + "a", + "an", + "and", + "after", + "are", + "about", + "be", + "before", + "by", + "can", + "do", + "does", + "for", + "from", + "how", + "in", + "is", + "it", + "of", + "on", + "or", + "post", + "prior", + "the", + "to", + "what", + "when", + "where", + "which", + "who", + "why", + "should", + "dose", + "dosing", + "dosage", + "medication", + "medicine", + "monitoring", + "monitor", + "baseline", + "tests", + "result", + "results", + "pathway", + "referral", + "patient", + "patients", + "required", + "requires", + "clinical", + "advice", + "contraindication", + "contraindications", + "documents", + "document", + "support", + "supports", + "supported", + "sources", + "source", + "guidance", + "guideline", + "guidelines", + "please", +]); + +const answerIntentTerms = new Set([ + "action", + "actions", + "avoid", + "contraindicated", + "contraindication", + "contraindications", + "criteria", + "dose", + "doses", + "dosing", + "dosage", + "maximum", + "max", + "monitor", + "monitors", + "monitoring", + "pathway", + "refer", + "referral", + "renal", + "result", + "results", + "required", + "requires", + "schedule", + "threshold", + "thresholds", + "what", +]); + +export function classifyAnswerIntent(query: string, queryClass: RagQueryClass): AnswerIntent { + const normalized = normalizeSectionText(query).toLowerCase(); + if (!normalized) return "unsupported"; + if ( + /\b(?:what|which|list|show|find)\s+(?:documents?|sources?|guidelines?|files?)\b.*\b(?:support|cover|contain|for|about)\b/.test( + normalized, + ) || + /\b(?:documents?|sources?|guidelines?|files?)\s+(?:support|cover|contain|for|about)\b/.test(normalized) + ) { + return "document_lookup"; + } + if (/\b(?:contraindicat\w*|avoid|do not use|must not|should not|not use|opioid[-\s]?free)\b/.test(normalized)) { + return "contraindication"; + } + const hasResultActionSignal = + /\b(?:red|amber|green|anc|fbc|wbc|result|results|threshold|withhold|cease|stop|stopped|toxicity)\b/.test( + normalized, + ) || /\b(?:what\s+action|action\s+is\s+required|required\s+action|suspected\s+\w+\s+toxicity)\b/.test(normalized); + const hasScheduleSignal = /\b(?:monitor|monitoring|schedule|baseline|follow[-\s]?up|level|levels|test|tests)\b/.test( + normalized, + ); + // Toxicity and explicit action queries take priority over monitoring even if schedule/baseline/follow-up terms appear. + const hasStrongResultSignal = + /\b(?:toxicity|what\s+action|action\s+is\s+required|required\s+action|suspected\s+\w+\s+toxicity)\b/.test( + normalized, + ); + if ( + hasResultActionSignal && + (!/\b(?:schedule|baseline|follow[-\s]?up)\b/.test(normalized) || hasStrongResultSignal) + ) { + return "red_result_action"; + } + if (hasScheduleSignal) { + return "monitoring_schedule"; + } + if (hasResultActionSignal) return "red_result_action"; + if (/\b(?:dose|dosing|dosage|max(?:imum)?|mg|mcg|renal|eGFR|creatinine)\b/i.test(query)) return "dose"; + if (/\b(?:pathway|refer|referral|criteria|ect|electroconvulsive)\b/.test(normalized)) return "pathway_referral"; + if ( + queryClass === "document_lookup" || + /\b(?:find|show|open|which)\b.*\b(?:document|guideline|procedure|policy|protocol|form|source|file)\b/.test( + normalized, + ) || + /\b(?:documentation|forms?|documents?|sources?|guidelines?|procedure|policy|protocol)\b/.test(normalized) + ) { + return "document_lookup"; + } + if (queryClass === "unsupported_or_general" && !clinicalQuerySignalPattern.test(query)) return "unsupported"; + return "general"; +} + +function queryEntityTokens(query: string, intent: AnswerIntent) { + const tokens = extractiveQueryTokens(query).filter((token) => !answerIntentTerms.has(token)); + if (intent === "document_lookup") return tokens.filter((token) => token.length > 3); + return tokens; +} + +function uniqueAnswerTokens(tokens: string[]) { + return Array.from(new Set(tokens.filter(Boolean))); +} + +function queryIntentTokens(query: string, intent: AnswerIntent) { + const tokens = extractiveQueryTokens(query).filter((token) => answerIntentTerms.has(token)); + if (intent === "dose" && /\b(?:renal|egfr|creatinine|kidney)\b/i.test(query)) + return uniqueAnswerTokens(["renal", ...tokens]); + if (intent === "dose" && /\bmax(?:imum)?\b/i.test(query)) return uniqueAnswerTokens(["maximum", ...tokens]); + if (intent === "monitoring_schedule") return uniqueAnswerTokens(["monitoring", ...tokens]); + if (intent === "red_result_action") + return uniqueAnswerTokens(["red", "range", "blood", "result", "results", "threshold", "action", ...tokens]); + if (intent === "contraindication") return uniqueAnswerTokens(["contraindication", ...tokens]); + if (intent === "pathway_referral") return uniqueAnswerTokens(["referral", "criteria", ...tokens]); + return tokens; +} + +function answerIntentEvidencePattern(intent: AnswerIntent) { + switch (intent) { + case "dose": + return /\b(?:doses?|dosing|dosage|max(?:imum)?|mg|mcg|microgram|micrograms|mmol\/l|eGFR|renal|creatinine|daily|bd|tds|mane|nocte)\b/i; + case "contraindication": + return /\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/i; + case "monitoring_schedule": + return /\b(?:monitor|monitoring|baseline|weekly|monthly|annual|every|level|levels|blood test|fbc|anc|ecg|lft|renal|review)\b/i; + case "red_result_action": + return /\b(?:red|amber|green|threshold|withhold|cease|stop|discontinue|discontinued|urgent|contact|repeat|review|anc|fbc|wbc|neutrophil|toxic\w*|action|patholog\w*|haematolog\w*|hematolog\w*)\b/i; + case "pathway_referral": + return /\b(?:pathway|refer|referral|criteria|indicat\w*|ect|electroconvulsive|specialist|psychiat\w*)\b/i; + case "document_lookup": + return /\b(?:document|guideline|procedure|policy|protocol|form|source|file|support|supports|covers|contains)\b/i; + default: + return /\b(?:assess|arrange|check|continue|review|treat|manage|monitor|refer|dose|risk|therapy|diagnos\w*)\b/i; + } +} + +function resultCoversAnswerIntent(result: SearchResult, query: string, intent: AnswerIntent) { + if (intent === "unsupported") return false; + const text = evidenceTextForGate(result); + const entityTokens = queryEntityTokens(query, intent); + const intentTokens = queryIntentTokens(query, intent); + const entityCoverage = + entityTokens.length === 0 || + entityTokens.some((token) => queryTokenMatchesText(token, text)) || + (/\bect\b/i.test(query) && /\b(?:ect|electroconvulsive)\b/i.test(text)); + if (!entityCoverage) return false; + if (intent === "general") return true; + const intentCoverage = answerIntentEvidencePattern(intent).test(text); + if (!intentCoverage) return false; + if (intentTokens.length > 0 && !intentTokens.some((token) => queryTokenMatchesText(token, text))) return false; + if (/\brenal\b/i.test(query) && !/\b(?:renal|kidney|eGFR|creatinine)\b/i.test(text)) return false; + if (/\bmax(?:imum)?\b/i.test(query) && !/\b(?:max(?:imum)?|\d+(?:\.\d+)?\s?(?:mg|mcg))\b/i.test(text)) { + return false; + } + return true; +} +const extractiveTruncationPattern = + /\b(?:stabili[sz]e\s+the\s+do|the\s+do\b|liver\s+functi\b|respiratio\b|if\s+a\s+60%\s+decrease\s+in\s+b\b)\b/i; +const extractiveProductCataloguePattern = + /\b(?:Lithicarb|Quilonum\s+SR|Campral|imprest\s+location|formulary\s+one)\b|[®™]/i; +const extractiveStructuralArtifactPattern = + /\b(?:for\s+required,|monitoringup|prnselection|druguse|anddoses|reviewresponse|maximumrecommendeddoses|recommendeddoses|information:\s*review|links\s+to\s+relevant\s+documents\/resources|pharmacy\s+services\s+and\s+dispensing\s+protocol|role\s+responsibilities|document\s+control|straight\s+to\s+the\s+point\s+of\s+care|full\s+text|pubmed|randomi[sz]ed\s+clinical\s+trial|j\s+psychiatry|ann\s+emerg\s+med|site\s+map|gpo,\s+perth|tel:\s*\(|fax:\s*\()\b/i; +const extractiveHeadingOnlyPattern = + /^(?:dosage?|dosing|monitoring|baseline tests?|therapy|source|section|table|guideline|referral criteria|criteria)(?:\s*\([^)]{1,80}\))?\.?$/i; +const extractiveAllowedLowercaseStarterPattern = + /^(?:if|when|for|in|avoid|do|must|withhold|cease|stop|monitor|check|reduce|increase|adjust|start|commence|begin|use|target|baseline|serum|therapy|dosing|titrate|arrange|refer|review|prescribe|record|complete|continue|discontinue|escalate)\b/i; +const extractiveConcreteDosePattern = + /\b(?:\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms|mmol\/?l)|mmol\/l|daily|bd|tds|mane|nocte|target|range|serum|levels?|titration|titrate|titrated|adjust(?:ed|ment)?|dose\s+(?:adjust|reduc|increas)|reduce(?:d)?\s+doses?|doses?\s+(?:in|for|when|with|based|according)|max(?:imum)?|renal|eGFR|CrCl|creatinine|elderly|impairment|conventional tablets?)\b/i; +const extractiveMedicationEntityPattern = + /\b(?:acamprosate|aripiprazole|baclofen|citalopram|clozapine|diazepam|disulfiram|droperidol|escitalopram|fluoxetine|haloperidol|lithium|lorazepam|naltrexone|olanzapine|promethazine|quetiapine|risperidone|sertraline|valproate)\b/gi; + +function extractiveQueryTokens(query: string) { + return splitBalancedWords(query).filter((token) => token.length > 2 && !extractiveQueryStopwords.has(token)); +} + +function escapeQueryToken(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function queryTokenVariants(token: string) { + const variants = new Set([token]); + if (token.length > 5 && token.endsWith("ing")) variants.add(token.slice(0, -3)); + if (token.length > 4 && token.endsWith("ies")) variants.add(`${token.slice(0, -3)}y`); + if (token.length > 4 && token.endsWith("es")) variants.add(token.slice(0, -2)); + if (token.length > 4 && token.endsWith("s")) variants.add(token.slice(0, -1)); + return [...variants].filter((variant) => variant.length > 2); +} + +function queryTokenMatchesText(token: string, text: string) { + if (token === "ect") return /\b(?:ect|electroconvulsive)\b/i.test(text); + for (const variant of queryTokenVariants(token)) { + const pattern = + variant.length <= 3 + ? new RegExp(`\\b${escapeQueryToken(variant)}\\b`, "i") + : new RegExp(`\\b${escapeQueryToken(variant)}\\w*\\b`, "i"); + if (pattern.test(text)) return true; + } + return false; +} + +function medicationEntitiesInText(text: string) { + extractiveMedicationEntityPattern.lastIndex = 0; + return Array.from(new Set((text.match(extractiveMedicationEntityPattern) ?? []).map((match) => match.toLowerCase()))); +} + +function mentionsDifferentMedicationEntity(sentence: string, query: string) { + const queryMedicationEntities = medicationEntitiesInText(query); + if (!queryMedicationEntities.length) return false; + return medicationEntitiesInText(sentence).some((entity) => !queryMedicationEntities.includes(entity)); +} + +function hasRelevantQueryOverlap( + text: string, + query: string, + intent: AnswerIntent = classifyAnswerIntent(query, classifyRagQuery(query).queryClass), +) { + const tokens = extractiveQueryTokens(query); + if (!tokens.length) return true; + const normalized = normalizeSectionText(text).toLowerCase(); + const entityTokens = queryEntityTokens(query, intent); + const intentTokens = queryIntentTokens(query, intent); + const entityCovered = + entityTokens.length === 0 || + entityTokens.some((token) => queryTokenMatchesText(token, normalized)) || + (/\bect\b/i.test(query) && /\b(?:ect|electroconvulsive)\b/i.test(normalized)); + if (!entityCovered && intent !== "general") return false; + if (intent === "general" || intent === "unsupported") + return tokens.some((token) => queryTokenMatchesText(token, text)); + return ( + answerIntentEvidencePattern(intent).test(normalized) && + (intentTokens.length === 0 || intentTokens.some((token) => queryTokenMatchesText(token, normalized))) + ); +} + +function hasBadExtractiveQuality(text: string) { + const normalized = normalizeSectionText(text); + if (!normalized) return true; + if (extractiveTruncationPattern.test(normalized)) return true; + if (extractiveProductCataloguePattern.test(normalized)) return true; + if (extractiveStructuralArtifactPattern.test(normalized)) return true; + if (extractiveHeadingOnlyPattern.test(normalized.replace(/[.;]+$/, ""))) return true; + if (/^([A-Za-z][A-Za-z /-]{3,60})\s+\1\.?$/i.test(normalized)) return true; + const firstToken = normalized.split(/\s+/, 1)[0] ?? ""; + if ( + /^[a-z][a-z -]{2,}\b/.test(normalized) && + !extractiveAllowedLowercaseStarterPattern.test(normalized) && + !medicationEntitiesInText(firstToken).length + ) { + return true; + } + if (/\b[A-Za-z]{4,}[A-Z]{2,}[A-Za-z]{2,}\b/.test(normalized)) return true; + // Narrow consecutive-arrow check: only flag '>>' that is NOT a clinical comparator like 'QTc >500 ms'. + // Two adjacent > with only whitespace between them (not digits/letters) signals markup artifacts. + if (/>[\s]*>/.test(normalized) && !/\w\s*>\s*\d/.test(normalized)) return true; // consecutive >> arrows + if (/\w+\s*>\s*\w+\s*>\s*\w+/g.test(normalized) && !/\d\s*>\s*\d/.test(normalized)) return true; // breadcrumb trails like A > B > C (not numeric ranges) + if ( + /^\s*(?:references?(?!\s+(?:range|interval|value|level|limit|coordinate|check|system|dosing|monitoring|guideline))|bibliography)\b/i.test( + normalized, + ) + ) + return true; + if (hasClinicalAnswerQualityIssue(normalized)) return true; + if (/\btable\s+\d+\b/i.test(normalized) && normalized.length > 180) return true; + return false; +} + +/** + * Quality gate for *completed* answers at the final validation step. + * + * Unlike `hasBadExtractiveQuality`, this version skips `extractiveProductCataloguePattern` + * because final answers to brand, PBS/access, or product-form questions legitimately contain + * medication brand names (Campral, Lithicarb, Quilonum SR) and ®/™ symbols. + * Applying the catalogue filter here would incorrectly replace valid answers with source-gap + * responses for those question types. + */ +function hasBadFinalAnswerQuality(text: string) { + const normalized = normalizeSectionText(text); + if (!normalized) return true; + if (extractiveTruncationPattern.test(normalized)) return true; + // Note: extractiveProductCataloguePattern is intentionally excluded here — see JSDoc above. + if (extractiveStructuralArtifactPattern.test(normalized)) return true; + if (extractiveHeadingOnlyPattern.test(normalized.replace(/[.;]+$/, ""))) return true; + if (/^([A-Za-z][A-Za-z /-]{3,60})\s+\1\.?$/i.test(normalized)) return true; + const firstToken = normalized.split(/\s+/, 1)[0] ?? ""; + if ( + /^[a-z][a-z -]{2,}\b/.test(normalized) && + !extractiveAllowedLowercaseStarterPattern.test(normalized) && + !medicationEntitiesInText(firstToken).length + ) { + return true; + } + if (/\b[A-Za-z]{4,}[A-Z]{2,}[A-Za-z]{2,}\b/.test(normalized)) return true; + if (/>[\s]*>/.test(normalized) && !/\w\s*>\s*\d/.test(normalized)) return true; + if (/\w+\s*>\s*\w+\s*>\s*\w+/g.test(normalized) && !/\d\s*>\s*\d/.test(normalized)) return true; + if ( + /^\s*(?:references?(?!\s+(?:range|interval|value|level|limit|coordinate|check|system|dosing|monitoring|guideline))|bibliography)\b/i.test( + normalized, + ) + ) + return true; + if (hasClinicalAnswerQualityIssue(normalized)) return true; + if (/\btable\s+\d+\b/i.test(normalized) && normalized.length > 180) return true; + return false; +} + +function isLowValueExtractiveCaption(clause: string) { + const descriptor = + /^(?:clinical\s+table|table|figure|image)\s+(?:showing|detailing|listing|outlining|describing|with|of)\b/i.test( + clause, + ) || /\btable\s+(?:showing|detailing|listing|outlining|describing)\b/i.test(clause); + if (!descriptor) return false; + return !extractiveClinicalDirectivePattern.test(clause); +} + +function splitClinicalEvidenceSentences(value: string) { + return sourceTextForClinicalProse(value) + .split(/\r?\n+|(?<=[.!?])\s+|\s+[•]\s+|\s+\|\s+/) + .map(cleanExtractivePointText) + .filter( + (sentence) => + sentence.length >= 18 && + !looksLikeJsonArtifact(sentence) && + !isLowValueExtractiveCaption(sentence) && + !hasBadExtractiveQuality(sentence), + ); +} + +function factKindForSentence(sentence: string, query: string, intent: AnswerIntent): ExtractedClinicalFactKind | null { + const text = normalizeSectionText(sentence); + if (!text) return null; + if ( + /\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/i.test( + text, + ) + ) { + return "contraindication"; + } + if (/\b(?:renal|kidney|eGFR|creatinine|CrCl)\b/i.test(text)) return "renal_limit"; + if ( + /\b(?:red|amber|green|threshold|withhold|cease|stop|discontinue|discontinued|urgent|contact|repeat|anc|fbc|wbc|neutrophil|toxic\w*|action)\b/i.test( + text, + ) + ) { + return "threshold_action"; + } + if (/\b(?:pathway|refer|referral|criteria|indicat\w*|ect|electroconvulsive|specialist|psychiat\w*)\b/i.test(text)) { + return "pathway_referral"; + } + if ( + /\b(?:monitor|monitoring|baseline|weekly|monthly|annual|every|level|levels|blood test|ecg|lft|review)\b/i.test(text) + ) { + return "monitoring"; + } + if ( + /\b(?:doses?|dosing|dosage|max(?:imum)?|\d+(?:\.\d+)?\s?(?:mg|mcg)|daily|bd|tds|mane|nocte|mmol\/l)\b/i.test(text) + ) { + return "dose"; + } + if (/\b(?:caution|risk|adverse|side effect|limited|not enough|insufficient)\b/i.test(text)) return "caveat"; + if (intent === "general" && hasRelevantQueryOverlap(text, query, intent)) return "bottom_line"; + return null; +} + +function factSupportsAnswerIntent( + kind: ExtractedClinicalFactKind, + sentence: string, + query: string, + intent: AnswerIntent, +) { + const text = normalizeSectionText(sentence); + const normalizedQuery = normalizeSectionText(query).toLowerCase(); + if (!text || hasBadExtractiveQuality(text)) return false; + + switch (intent) { + case "dose": + if (kind !== "dose" && kind !== "renal_limit") { + // Allow contraindication facts when the query explicitly asks for renal information, + // since renal contraindications (e.g. creatinine >120 micromol/L: contraindicated) are + // essential dose safety facts for renal-dose queries. + if ( + kind === "contraindication" && + /\brenal\b/i.test(query) && + /\b(?:renal|kidney|eGFR|creatinine|CrCl)\b/i.test(text) + ) { + // fall through to dose text check below + } else { + return false; + } + } + if (/\brenal\b/i.test(query) && !/\b(?:renal|kidney|eGFR|creatinine|CrCl)\b/i.test(text)) return false; + if (/\bmax(?:imum)?\b/i.test(query) && !/\b(?:max(?:imum)?|\d+(?:\.\d+)?\s?(?:mg|mcg))\b/i.test(text)) { + return false; + } + return extractiveConcreteDosePattern.test(text); + case "contraindication": + return ( + kind === "contraindication" && + /\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/i.test( + text, + ) + ); + case "monitoring_schedule": + // Also allow renal_limit facts — sentences like 'baseline renal function then repeat periodically' + // are classified as renal_limit (renal check triggers before monitoring), but are directly relevant + // to monitoring schedule answers. + if (kind !== "monitoring" && kind !== "dose" && kind !== "renal_limit") return false; + return /\b(?:monitor|monitoring|follow[-\s]?up|baseline|weekly|monthly|annual|every|several\s+times\s+a\s+year|level|levels|blood test|fbc|anc|wbc|ecg|lft|renal|thyroid|metabolic|glucose|bsl|lipids|cholesterol|triglycerides|blood pressure|bp|pulse|weight|bmi|mmol\/l|range)\b/i.test( + text, + ); + case "red_result_action": + if (kind !== "threshold_action" && kind !== "caveat") return false; + return ( + /\b(?:withhold|cease|stop|discontinue|discontinued|contact|urgent|repeat|review|call for help|escalat\w*|monitor|toxicity|rash)\b/i.test( + text, + ) && + // Include green/neutrophil: valid clozapine result-action vocabulary the classifier accepts + /\b(?:red|amber|green|threshold|result|results|anc|fbc|wbc|neutrophil|toxicity|rash|reaction|blood|patholog\w*|haematolog\w*|hematolog\w*)\b/i.test( + text, + ) + ); + case "pathway_referral": + if (kind !== "pathway_referral") return false; + if (/\breferr?al|refer\b/i.test(query) && !/\b(?:refer|referral|form\s*1a)\b/i.test(text)) return false; + if (/\bdischarge\s+criteria\b/i.test(text) && !/\bdischarge\b/.test(normalizedQuery)) return false; + return /\b(?:pathway|procedure|refer|referral|criteria|indicat\w*|ect|electroconvulsive|specialist|psychiat\w*|step)\b/i.test( + text, + ); + case "document_lookup": + return /\b(?:document|guideline|procedure|policy|protocol|form|source|file|support|supports|covers|contains)\b/i.test( + text, + ); + case "unsupported": + return false; + case "general": + default: + if (/\b(?:references?|bibliography|full\s+text|pubmed|randomi[sz]ed\s+clinical\s+trial)\b/i.test(text)) { + return false; + } + if (/^what\s+is\b/i.test(query)) { + return /\b(?:is|are|means|defined|characteri[sz]ed|involves|refers\s+to)\b/i.test(text); + } + return /\b(?:assess|arrange|check|continue|review|treat|manage|monitor|refer|dose|risk|therapy|diagnos\w*)\b/i.test( + text, + ); + } +} + +function factSentenceMatchesQueryFromResult( + sentence: string, + result: SearchResult, + query: string, + intent: AnswerIntent, +) { + if (mentionsDifferentMedicationEntity(sentence, query)) return false; + if (hasRelevantQueryOverlap(sentence, query, intent)) return true; + if (intent === "general" || intent === "unsupported") return false; + + const resultText = evidenceTextForGate(result); + const entityTokens = queryEntityTokens(query, intent); + const entityCoveredByResult = + entityTokens.length === 0 || entityTokens.some((token) => queryTokenMatchesText(token, resultText)); + if (!entityCoveredByResult) return false; + + const normalized = normalizeSectionText(sentence).toLowerCase(); + const intentTokens = queryIntentTokens(query, intent); + const intentCovered = + intentTokens.length === 0 || + intentTokens.some((token) => queryTokenMatchesText(token, normalized)) || + (intent === "dose" && extractiveConcreteDosePattern.test(normalized)); + return answerIntentEvidencePattern(intent).test(normalized) && intentCovered; +} + +function factPriority(kind: ExtractedClinicalFactKind, intent: AnswerIntent) { + if (intent === "contraindication" && kind === "contraindication") return 9; + if (intent === "red_result_action" && kind === "threshold_action") return 9; + if (intent === "monitoring_schedule" && kind === "monitoring") return 9; + if (intent === "pathway_referral" && kind === "pathway_referral") return 9; + if (intent === "dose" && kind === "dose") return 9; + if (intent === "dose" && kind === "renal_limit") return 8; + if (kind === "bottom_line") return 5; + if (kind === "caveat") return 3; + return 6; +} + +function tableFactsToClinicalFacts(result: SearchResult, query: string, intent: AnswerIntent): ExtractedClinicalFact[] { + return (result.table_facts ?? []) + .map((fact) => { + const text = cleanExtractivePointText( + [fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action].filter(Boolean).join(": "), + ); + const kind = factKindForSentence(text, query, intent); + if (!text || !kind || !factSentenceMatchesQueryFromResult(text, result, query, intent)) return null; + if (!factSupportsAnswerIntent(kind, text, query, intent)) return null; + return { + kind, + text, + citationChunkIds: [result.id], + priority: factPriority(kind, intent) + 1, + } satisfies ExtractedClinicalFact; + }) + .filter((fact): fact is ExtractedClinicalFact => Boolean(fact)); +} + +function extractClinicalFactsFromResults(results: SearchResult[], query: string, intent: AnswerIntent, limit = 8) { + const seen = new Set(); + const facts: ExtractedClinicalFact[] = []; + const usableResults = results.filter((result) => resultCoversAnswerIntent(result, query, intent)); + + for (const result of usableResults) { + for (const fact of tableFactsToClinicalFacts(result, query, intent)) { + const key = `${fact.kind}:${normalizeSectionText(fact.text).toLowerCase().slice(0, 160)}`; + if (seen.has(key)) continue; + seen.add(key); + facts.push(fact); + } + + const text = [ + result.retrieval_synopsis, + result.section_heading, + result.content, + result.adjacent_context, + ...(result.memory_cards ?? []).map((card) => card.content), + ] + .filter(Boolean) + .join("\n"); + for (const sentence of splitClinicalEvidenceSentences(text)) { + if (!factSentenceMatchesQueryFromResult(sentence, result, query, intent)) continue; + const kind = factKindForSentence(sentence, query, intent); + if (!kind) continue; + if (!factSupportsAnswerIntent(kind, sentence, query, intent)) continue; + const cleaned = sentence.length <= 280 ? sentence : `${sentence.slice(0, 277).trim()}...`; + const key = `${kind}:${normalizeSectionText(cleaned).toLowerCase().slice(0, 160)}`; + if (seen.has(key)) continue; + seen.add(key); + facts.push({ + kind, + text: cleaned, + citationChunkIds: [result.id], + priority: factPriority(kind, intent) + Math.min(scoreValue(result), 1), + }); + if (facts.length >= limit) break; + } + if (facts.length >= limit) break; + } + + return facts.sort((a, b) => b.priority - a.priority || a.text.length - b.text.length).slice(0, limit); +} + +function sentenceFromFact(fact: ExtractedClinicalFact, query: string) { + const text = sanitizeAnswerText(cleanExtractivePointText(fact.text)).replace(/[.;,\s]+$/, ""); + if (!text) return ""; + const entity = queryEntityTokens(query, classifyAnswerIntent(query, classifyRagQuery(query).queryClass))[0]; + const needsEntityPrefix = + entity && + fact.kind !== "bottom_line" && + !queryTokenMatchesText(entity, text) && + !/^(?:for|in|when|if|avoid|do not|must not|withhold|cease|stop|monitor|check|refer|arrange)\b/i.test(text); + const sentence = needsEntityPrefix ? `For ${entity}, ${text.charAt(0).toLowerCase()}${text.slice(1)}` : text; + return completeExtractiveSentence(sentence, query); +} + +function lowerFirst(value: string) { + if (!value) return value; + return `${value.charAt(0).toLowerCase()}${value.slice(1)}`; +} + +function upperFirst(value: string) { + if (!value) return value; + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +// A clinical action clause: an imperative/directive verb that turns a bare conditional +// ("if INR is high") into a complete, self-contained sentence ("if INR is high, withhold warfarin"). +const extractiveActionClausePattern = + /\b(?:withhold|cease|stop|discontinue|hold|monitor|check|repeat|review|refer|arrange|contact|escalate|seek|avoid|continue|commence|start|initiate|titrate|prescribe|administer|give|reduce|increase|document|consider|recheck|admit|transfer)\b/i; + +export function completeExtractiveSentence(value: string, query: string) { + const cleaned = sanitizeAnswerText(value) + .replace(/[.;,\s]+$/, "") + .trim(); + if (!cleaned) return ""; + + const sentence = `${cleaned}.`; + if (hasCompleteOpeningSentence(sentence) && !isFragmentLikeClinicalAnswer(sentence, query)) return sentence; + + if (/^(?:when|if|where|after|before|during)\b/i.test(cleaned)) { + // A conditional clause that already carries its own action ("if INR is high, withhold warfarin") + // is a complete, natural sentence — present it directly instead of the stock "The guidance is + // that…" lead-in. Only when the condition has no action of its own do we add the wrapper so the + // fragment reads as a full sentence. + const conditionalAsSentence = `${upperFirst(cleaned)}.`; + if ( + /,\s*\S/.test(cleaned) && + extractiveActionClausePattern.test(cleaned) && + !isFragmentLikeClinicalAnswer(conditionalAsSentence, query) + ) { + return conditionalAsSentence; + } + return `The guidance is that ${lowerFirst(cleaned)}.`; + } + + const withoutLeadingFragment = cleaned.replace(/^(?:and|or|but|with|without|including|such as|then)\s+/i, ""); + if (/^to\b/i.test(cleaned)) { + return `The guidance is ${lowerFirst(cleaned)}.`; + } + if (withoutLeadingFragment !== cleaned) { + return `The guidance includes ${lowerFirst(withoutLeadingFragment)}.`; + } + + return `The guidance is that ${lowerFirst(cleaned)}.`; +} + +function sectionForFactKind(kind: ExtractedClinicalFactKind): Pick { + switch (kind) { + case "dose": + return { heading: "Dose", kind: "medication_dose" }; + case "renal_limit": + return { heading: "Renal limits", kind: "contraindications_cautions" }; + case "monitoring": + return { heading: "Monitoring", kind: "monitoring_timing" }; + case "threshold_action": + return { heading: "Result action", kind: "thresholds" }; + case "contraindication": + return { heading: "Contraindications", kind: "contraindications_cautions" }; + case "pathway_referral": + return { heading: "Pathway/referral", kind: "required_actions" }; + case "caveat": + return { heading: "Caveat", kind: "source_gap" }; + default: + return { heading: "Bottom line", kind: "bottom_line" }; + } +} + +function buildFactSections(facts: ExtractedClinicalFact[], query: string) { + const grouped = new Map(); + for (const fact of facts) grouped.set(fact.kind, [...(grouped.get(fact.kind) ?? []), fact]); + return Array.from(grouped.entries()) + .slice(0, 4) + .map(([kind, group]) => { + const section = sectionForFactKind(kind); + const body = group + .slice(0, 2) + .map((fact) => sentenceFromFact(fact, query)) + .filter(Boolean) + .join(" "); + return { + heading: section.heading, + kind: section.kind, + supportLevel: "direct", + body: boldHighYieldClinicalText(body, query), + citation_chunk_ids: Array.from(new Set(group.flatMap((fact) => fact.citationChunkIds))), + } satisfies AnswerSection; + }) + .filter((section) => section.body && section.citation_chunk_ids.length > 0); +} + +function buildFactSynthesizedAnswer(args: { + query: string; + queryClass: RagQueryClass; + intent: AnswerIntent; + results: SearchResult[]; +}) { + const facts = extractClinicalFactsFromResults(args.results, args.query, args.intent); + if (!facts.length) { + if (sourceBackedDocumentFallbackIntent(args.query, args.queryClass, args.intent, args.results)) { + return buildDocumentSupportListAnswer({ query: args.query, results: args.results }); + } + const gapAnswer = finalQualityGapAnswer(args.query, args.queryClass, args.intent); + return { + answer: gapAnswer, + body: gapAnswer, + citationChunkIds: [] as string[], + answerSections: [] as AnswerSection[], + }; + } + + const leadFacts = facts.slice(0, args.intent === "dose" ? 2 : 1); + const answer = sanitizeAnswerText( + leadFacts + .map((fact) => sentenceFromFact(fact, args.query)) + .filter(Boolean) + .join(" "), + ); + const answerSections = buildFactSections(facts, args.query); + return { + answer: boldHighYieldClinicalText(answer, args.query), + body: boldHighYieldClinicalText(answer, args.query), + citationChunkIds: Array.from(new Set(facts.flatMap((fact) => fact.citationChunkIds))), + answerSections, + }; +} + +function sourceBackedDocumentFallbackIntent( + query: string, + queryClass: RagQueryClass, + intent: AnswerIntent, + results: SearchResult[], +) { + if (results.length === 0) return false; + const strongestScore = Math.max(...results.map(scoreValue)); + if (strongestScore < 0.45) return false; + const normalized = normalizeSectionText(query).toLowerCase(); + const sourceBackedProcedureQuery = + /\b(?:process|procedure|protocol|pathway|workflow|steps?|requirements?|criteria|guidance|document)\b/.test( + normalized, + ); + if (!sourceBackedProcedureQuery) return false; + return ( + intent === "document_lookup" || + intent === "pathway_referral" || + queryClass === "document_lookup" || + queryClass === "broad_summary" + ); +} + +function documentSupportListIntent(query: string, queryClass: RagQueryClass) { + return ( + classifyAnswerIntent(query, queryClass) === "document_lookup" && + /\b(?:support|supports|supporting|sources?|documents?|guidelines?)\b/i.test(query) + ); +} + +function tableOrVisualSourceLookupIntent(query: string, queryClass: RagQueryClass, answerIntent: AnswerIntent) { + if (queryClass === "table_threshold" || answerIntent === "dose" || answerIntent === "monitoring_schedule") + return false; + return ( + /\b(?:which|where|find|open|locate)\b.{0,120}\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( + query, + ) || + /\b(?:show|display)\s+(?:me\s+)?(?:the\s+)?(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( + query, + ) || + /\b(?:which|what)\b.{0,80}\b(?:source|document|guideline|file|pdf)\b.{0,80}\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( + query, + ) || + /\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b.{0,80}\b(?:cover|covers|contain|contains|list|lists|guidance)\b/i.test( + query, + ) + ); +} + +function sourceLookupLabel(result: SearchResult) { + const tableTitle = (result.table_facts ?? []) + .map((fact) => fact.table_title || fact.row_label) + .find((value): value is string => Boolean(value?.trim())); + const imageTitle = (result.images ?? []) + .map((image) => image.tableTitle || image.caption) + .find((value): value is string => Boolean(value?.trim())); + const rawLabel = tableTitle || imageTitle || result.section_heading || result.title || result.file_name; + return normalizeSectionText(rawLabel) + .replace(/([a-zA-Z])\(/g, "$1 (") + .replace(/\s{2,}/g, " ") + .trim(); +} + +function hasTableOrVisualLookupEvidence(result: SearchResult) { + return ( + (result.table_facts?.length ?? 0) > 0 || + (result.images ?? []).some((image) => + /\b(?:clinical_table|flowchart_algorithm|medication_chart|risk_matrix|table_crop|diagram_crop|page_region|embedded)\b/i.test( + `${image.image_type ?? ""} ${image.sourceKind ?? ""} ${image.source_kind ?? ""}`, + ), + ) || + /\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( + `${result.section_heading ?? ""} ${result.title ?? ""} ${result.file_name ?? ""}`, + ) + ); +} + +function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: SearchResult[] }) { + const source = args.results.find(hasTableOrVisualLookupEvidence) ?? args.results[0]; + if (!source) { + const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup"); + return { answer: gapAnswer, citationChunkIds: [] as string[], answerSections: [] as AnswerSection[] }; + } + + const label = sourceLookupLabel(source) || "the top matched source"; + const answer = `The relevant source is ${label}, which covers the requested table or visual guidance.`; + + return { + answer, + citationChunkIds: [source.id], + preformatted: true, + answerSections: [ + { + heading: "Source match", + kind: "documentation", + supportLevel: "direct", + body: `The source match is ${label}.`, + citation_chunk_ids: [source.id], + }, + ] satisfies AnswerSection[], + }; +} + +function buildDocumentSupportListAnswer(args: { query: string; results: SearchResult[] }) { + const documents = buildDocumentBreakdown(args.results, extractQuoteCards(args.results, args.query)).slice(0, 5); + if (!documents.length) { + const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup"); + return { answer: gapAnswer, citationChunkIds: [] as string[], answerSections: [] as AnswerSection[] }; + } + const names = documents + .map((document) => + normalizeSectionText(document.title || document.file_name) + .replace(/([a-zA-Z])\(/g, "$1 (") + .replace(/\s{2,}/g, " ") + .trim(), + ) + .filter(Boolean); + const answer = + names.length === 1 + ? `I found one indexed document that supports this query: ${names[0]}.` + : `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`; + return { + answer, + preformatted: true, + citationChunkIds: Array.from( + new Set( + documents.flatMap((document) => + args.results + .filter((result) => result.document_id === document.document_id) + .slice(0, 1) + .map((result) => result.id), + ), + ), + ), + answerSections: [ + { + heading: "Document matches", + kind: "documentation", + supportLevel: "direct", + body: names.join("; "), + citation_chunk_ids: Array.from( + new Set( + documents.flatMap((document) => + args.results + .filter((result) => result.document_id === document.document_id) + .slice(0, 1) + .map((result) => result.id), + ), + ), + ), + }, + ] satisfies AnswerSection[], + }; +} + +export function buildExtractiveAnswer(args: { + query: string; + queryClass: RagQueryClass; + results: SearchResult[]; + quoteCards: QuoteCard[]; + documentBreakdown: RagAnswer["documentBreakdown"]; + evidenceSummary: RagAnswer["evidenceSummary"]; + sourceCoverage: RagAnswer["sourceCoverage"]; + conflictsOrGaps: ConflictOrGap[]; + visualEvidence: RagAnswer["visualEvidence"]; + bestSource: RagAnswer["bestSource"]; + smartPanel: RagAnswer["smartPanel"]; + relatedDocuments: RagAnswer["relatedDocuments"]; + routeReason: string; + timings: RagAnswer["latencyTimings"]; +}) { + const quoteCards = args.quoteCards.length + ? args.quoteCards.slice(0, 5) + : extractQuoteCards(args.results, args.query, 5); + const memoryCards = rankMemoryCardsForAnswer(collectMemoryCards(args.results, 16), args.query, args.queryClass).slice( + 0, + 10, + ); + const citations = compactCitations(args.results).slice(0, Math.max(quoteCards.length, 1)); + const citationIds = new Set(citations.map((citation) => citation.chunk_id)); + const resultById = new Map(args.results.map((result) => [result.id, result])); + for (const card of memoryCards) { + for (const chunkId of card.source_chunk_ids ?? []) { + if (citationIds.has(chunkId)) continue; + const source = resultById.get(chunkId); + if (!source) continue; + citations.push(resultCitation(source)); + citationIds.add(chunkId); + } + } + for (const quote of quoteCards) { + if (!citationIds.has(quote.chunk_id)) { + // Guard the lookup: a quote card whose chunk_id was filtered out of results + // would make find() return undefined and resultCitation(undefined) throw. + const source = args.results.find((result) => result.id === quote.chunk_id); + if (source) citations.push(resultCitation(source)); + } + citationIds.add(quote.chunk_id); + } + + const answerIntent = classifyAnswerIntent(args.query, args.queryClass); + const naturalAnswer = documentSupportListIntent(args.query, args.queryClass) + ? buildDocumentSupportListAnswer({ query: args.query, results: args.results }) + : tableOrVisualSourceLookupIntent(args.query, args.queryClass, answerIntent) + ? buildTableOrVisualSourceLookupAnswer({ query: args.query, results: args.results }) + : buildFactSynthesizedAnswer({ + query: args.query, + queryClass: args.queryClass, + intent: answerIntent, + results: args.results, + }); + + // Fact synthesis is the production extractive path. If no clean fact survives + // coverage and artifact gates, fail closed instead of stitching snippets. + const hasExtractedAnswer = naturalAnswer.citationChunkIds.length > 0; + + // Ensure any chunk IDs referenced by the synthesized answer are present in citations, + // even if they were not in the top-ranked compactCitations slice. + for (const chunkId of naturalAnswer.citationChunkIds) { + if (!citationIds.has(chunkId)) { + const source = args.results.find((result) => result.id === chunkId); + if (source) { + citations.push(resultCitation(source)); + citationIds.add(chunkId); + } + } + } + + return { + answer: naturalAnswer.answer, + grounded: hasExtractedAnswer && citations.length > 0, + confidence: hasExtractedAnswer ? deriveConfidence(args.results, citations) : "unsupported", + citations: citations.slice(0, 5), + sources: args.results, + modelUsed: null, + routingMode: "extractive", + preformatted: hasExtractedAnswer && Boolean((naturalAnswer as { preformatted?: boolean }).preformatted), + routingReason: args.routeReason, + queryClass: args.queryClass, + latencyTimings: args.timings, + answerSections: naturalAnswer.answerSections ?? [], + quoteCards, + visualEvidence: args.visualEvidence, + bestSource: args.bestSource, + documentBreakdown: args.documentBreakdown, + evidenceSummary: args.evidenceSummary, + sourceCoverage: args.sourceCoverage, + conflictsOrGaps: args.conflictsOrGaps, + smartPanel: args.smartPanel, + relatedDocuments: args.relatedDocuments, + memoryCardsUsed: memoryCards, + indexingVersion: ragDeepMemoryVersion, + indexingQuality: buildIndexingQuality(args.results, memoryCards), + scoreExplanations: buildAnswerScoreExplanations(args.results), + } satisfies RagAnswer; +} + +function sourceBackedFallbackSubject(query: string) { + const normalized = normalizeSectionText(query) + .replace(/[?!.]+$/, "") + .trim(); + const subject = normalized + .replace(/^summari[sz]e\s+(?:the\s+)?/i, "") + .replace(/^what\s+(?:is|are)\s+(?:the\s+)?(?:process|requirements?)\s+for\s+/i, "") + .replace(/^what\s+(?:is|are)\s+required\s+(?:for|when)\s+/i, "") + .replace(/^what\s+(.+?)\s+is\s+required$/i, "$1") + .replace(/^what\s+does\s+(?:the\s+)?/i, "") + .replace(/\s+(?:document|procedure|guideline)\s+require$/i, "") + .replace(/^how\s+(?:is|are)\s+/i, "") + .replace(/\s+managed$/i, " management") + .trim(); + + if (subject.length < 4) return "this clinical question"; + return subject.length > 90 ? `${subject.slice(0, 87).trim()}...` : lowerFirst(subject); +} + +export function sourceBackedGenerationTimeoutAnswer(query: string) { + const subject = sourceBackedFallbackSubject(query); + return `The uploaded documents contain relevant guidance on ${subject}, but a full written answer could not be completed just now. The key source passages are cited below — please review them directly.`; +} + +const reasoningEffortRank: Record = { + none: 0, + low: 1, + medium: 2, + high: 3, + xhigh: 4, +}; + +// Strong-route reasoning effort by query class (P6). Safety-critical numeric/threshold classes keep +// the full configured effort; routine retrieval classes are capped at "medium" so high-effort +// reasoning over verbose context does not overrun the answer timeout and fail-closed on queries that +// actually have good sources. Never raises effort above the configured value. +export function strongReasoningEffortForQueryClass( + queryClass: RagQueryClass, + configured: OpenAIReasoningEffort, +): OpenAIReasoningEffort { + const safetyCritical = queryClass === "medication_dose_risk" || queryClass === "table_threshold"; + if (safetyCritical) return configured; + return reasoningEffortRank[configured] > reasoningEffortRank.medium ? "medium" : configured; +} + +export function isUnusableGeneratedAnswer(answer: Pick) { + const normalized = normalizeSectionText(answer.answer ?? ""); + if (!normalized) return true; + if (normalized === machineReadableFallbackAnswer) return true; + if (answer.routingReason === "structured_parse_fallback") return true; + return looksLikeJsonArtifact(normalized); +} + +const templateLikeGeneratedTextPattern = + /\b(?:the\s+(?:strongest\s+)?retrieved\s+(?:source|sources|passages|excerpts)\s+(?:support|supports|show|shows|indicate|indicates)|retrieved\s+(?:source|sources|passages|excerpts)|source-backed|based\s+on\s+(?:the\s+)?(?:provided\s+)?(?:sources|excerpts|passages|retrieved\s+sources)|the\s+(?:cited\s+)?source\s+(?:states|supports|says|indicates)|provided\s+excerpts)\b/i; +const templateLikeGeneratedPrefixPattern = /^(?:answer|summary|bottom line|required actions|direct answer)\s*[:.-]\s+/i; +const templateLikeGeneratedSectionHeadingPattern = + /^(?:direct answer|bottom line|high-yield summary|source-backed answer|direct source-backed answer)$/i; +const simpleDirectQuestionPattern = + /^(?:what\s+(?:is|are)|what's|define|who\s+(?:is|are)|when\s+(?:is|are)|where\s+(?:is|are)|is\s+|are\s+|does\s+|do\s+)/i; +const simpleQuestionExpansionPattern = + /\b(?:management|manage|managed|treatment|treat|therapy|care|approach|pathway|dose|dosing|threshold|compare|versus|vs|monitoring|required|requirements|risk|side effect|contraindicat\w*|urgent|escalat\w*)\b/i; + +export function isTemplateLikeGeneratedAnswer(answer: Pick) { + const answerText = normalizeSectionText(answer.answer ?? ""); + if ( + answerText && + (templateLikeGeneratedTextPattern.test(answerText) || templateLikeGeneratedPrefixPattern.test(answerText)) + ) { + return true; + } + + return (answer.answerSections ?? []).some((section) => { + const heading = normalizeSectionText(section.heading ?? ""); + const body = normalizeSectionText(section.body ?? ""); + return ( + (heading && templateLikeGeneratedSectionHeadingPattern.test(heading)) || + (body && (templateLikeGeneratedTextPattern.test(body) || templateLikeGeneratedPrefixPattern.test(body))) + ); + }); +} + +export function isSimpleDirectQuestion(query: string, queryClass: RagQueryClass) { + const normalized = normalizeSectionText(query); + if (!normalized || normalized.length > 100) return false; + if (queryClass === "comparison" || queryClass === "table_threshold" || queryClass === "medication_dose_risk") { + return false; + } + if (queryClass === "broad_summary" || queryClass === "document_lookup") return false; + return simpleDirectQuestionPattern.test(normalized) && !simpleQuestionExpansionPattern.test(normalized); +} + +// Bare definitional questions ("what is X", "define X", "who is X") legitimately get short answers +// that refer back to the subject with anaphora ("It is …") without repeating the entity term, so +// the lexical entity-overlap responsiveness check would false-fire on them. Detect and exempt them +// when extending the overlap gate to synthesized model answers. +export function isBareDefinitionQuestion(query: string) { + return /^(?:what(?:'s| is| are)|define|who\s+(?:is|are))\b/i.test(normalizeSectionText(query)); +} + +function wordCount(value: string) { + return normalizeSectionText(value).split(/\s+/).filter(Boolean).length; +} + +export function isOverExpandedSimpleGeneratedAnswer( + query: string, + queryClass: RagQueryClass, + answer: Pick, +) { + if (!isSimpleDirectQuestion(query, queryClass)) return false; + const sections = answer.answerSections ?? []; + const nonEssentialSectionCount = sections.filter((section) => !isEssentialSimpleQuestionSection(section)).length; + return nonEssentialSectionCount > 0 || sections.length > 1 || wordCount(answer.answer ?? "") > 95; +} + +function isEssentialSimpleQuestionSection(section: Pick) { + return /\b(?:gap|not enough|insufficient|unsupported|urgent|escalat\w*|risk|safety)\b/i.test( + `${section.heading} ${section.body}`, + ); +} + +const clinicalQuerySignalPattern = + /\b(?:lithium|clozapine|acamprosate|naltrexone|sertraline|valproate|antipsychotic|ect|bulimia|anorexia|eating disorder|dose|renal|pregnan|monitor|fbc|anc|qtc|opioid|contraindicat|referral|pathway|patient|clinical|guideline|medication|medicine|prescrib|therapy|treatment)\b/i; + +function isClearlyNonClinicalUnsupportedQuery(query: string) { + return ( + /\b(?:coffee|machine|parking|payroll|roster|leave|wifi|printer|canteen|expense|timesheet|room\s+booking|building|staff\s+room)\b/i.test( + query, + ) && !clinicalQuerySignalPattern.test(query) + ); +} + +export function finalQualityGapAnswer( + query: string, + queryClass: RagQueryClass, + intent: AnswerIntent = classifyAnswerIntent(query, queryClass), +) { + if ( + isClearlyNonClinicalUnsupportedQuery(query) || + (queryClass === "unsupported_or_general" && !clinicalQuerySignalPattern.test(query)) + ) { + return "No relevant clinical source was found for this query."; + } + if (intent === "document_lookup") return "No current indexed document directly supporting this request was found."; + if (intent === "pathway_referral" && /\bect\b/i.test(query)) { + return "No current source with ECT referral criteria was found."; + } + if (intent === "pathway_referral") return "No current source with referral or pathway criteria was found."; + if (intent === "contraindication") return "No current source with contraindication or avoid-use guidance was found."; + if (intent === "monitoring_schedule") + return "No current source with monitoring timing or schedule guidance was found."; + if (intent === "red_result_action") { + if (/\bqtc\b/i.test(query)) return "No current source with QTc threshold or ECG action guidance was found."; + if (/\btoxicity\b/i.test(query)) return "No current source with toxicity action guidance was found."; + if (/\brash\b/i.test(query)) return "No current source with rash action guidance was found."; + return "No current source with threshold-specific action guidance was found."; + } + if (intent === "dose") { + if (/\brenal\b/i.test(query)) return "No current source with renal dosing limits for this query was found."; + return "No current source with dose guidance for this query was found."; + } + return "No current source with directly relevant clinical guidance was found."; +} + +function isFragmentLikeClinicalAnswer(text: string, query: string) { + const normalized = normalizeSectionText(text); + const lower = normalized.toLowerCase(); + if ( + /\b(?:dosing\s+frequencies\s+outside|prn\s+dose\s+daily\s+dose|table\s+summari[sz]ing|includes\s+risk\s+monitoring\s+form|recommended\s+over\s+>\d+\s*kg)\b/i.test( + normalized, + ) + ) { + return true; + } + if (/^for\s+(?:after|before|prior|post),/i.test(normalized)) return true; + if (/\bis\s+to:\.?$/i.test(normalized)) return true; + if ( + /^what\s+is\b/i.test(query) && + // Only apply this fragment gate for general/definition questions, not for clinical intent + // queries like "What is the maximum dose?" or "What is the QTc threshold?" which produce + // valid concise fact answers that don't contain definition-style phrasing. + !/\b(?:required|requirements?|dose|dosage|dosing|max(?:imum)?|mg|mcg|threshold|monitor|renal|contraindicat|referral|pathway|procedure|process|protocol|workflow|steps?|ect|electroconvulsive|qtc|fbc|anc|wbc|level|levels)\b/i.test( + query, + ) && + // "What is required/needed/involved/included…" and "what is the process/procedure/protocol…" + // are procedural questions, not definitions — their answers (and the source-pointer fallback) + // legitimately lack "X is a/an…" definition phrasing, so the definition-fragment gate must not + // fire for them (it otherwise fails good answers closed on a false positive — see P6). + !/^what\s+is\s+(?:required|needed|involved|included|expected|recommended|considered|the\s+(?:process|procedure|protocol|criteria|requirement|approach|guidance|recommendation|role|purpose|aim))\b/i.test( + query, + ) && + !/\b(?:is|are)\s+(?:a|an|the)\b|\b(?:defined\s+as|characteri[sz]ed\s+by|involves|refers\s+to|is\s+an?\s+eating\s+disorder)\b/i.test( + normalized, + ) + ) { + return true; + } + if (/\bbaby\s+whilst\b.*\bpost\s+anaesthetic\b/i.test(normalized)) return true; + if (/^(\*{0,2}[a-z][a-z0-9 -]{2,}\*{0,2})\s*:\s*\1\b/i.test(normalized)) return true; + if (/\?\s+(?:monitoring|adverse effects|when prescribed|prescribed for)\b/i.test(normalized)) return true; + if (/\bmonitoring adverse effects when prescribed\b/i.test(normalized)) return true; + if (/\b(?:after starting|ongoing)\s+\*{0,2}[a-z]+\*{0,2}\.?$/i.test(normalized) && normalized.length < 90) { + return true; + } + if (/\bect\b/i.test(query) && !/\b(?:ect|electroconvulsive|refer|referral)\b/i.test(lower)) return true; + return false; +} + +function isMissingCriticalQueryIntent(query: string, text: string) { + const normalizedQuery = normalizeSectionText(query).toLowerCase(); + const normalizedText = normalizeSectionText(text).toLowerCase(); + if (/\bcontraindicat\w*\b/.test(normalizedQuery)) { + return !/\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/.test( + normalizedText, + ); + } + if (/\b(?:what to do|red|amber|anc|result|results)\b/.test(normalizedQuery)) { + return !/\b(?:withhold|cease|stop|discontinue|discontinued|contact|urgent|repeat|review|monitor|range|threshold|blood|patholog\w*|haematolog\w*|hematolog\w*|anc)\b/.test( + normalizedText, + ); + } + if (/\b(?:referral|refer|pathway)\b/.test(normalizedQuery) && /\bect\b/.test(normalizedQuery)) { + return !/\b(?:ect|electroconvulsive|refer|referral|criteria|indicat\w*|psychiat\w*)\b/.test(normalizedText); + } + if (/\b(?:monitor|monitoring|schedule|baseline|follow[-\s]?up)\b/.test(normalizedQuery)) { + if (/\bfbc\b/.test(normalizedQuery) && !/\bfbc\b/.test(normalizedText)) return true; + if (/\banc\b/.test(normalizedQuery) && !/\banc\b/.test(normalizedText)) return true; + if ( + /\bschedule\b/.test(normalizedQuery) && + !/\b(?:schedule|baseline|weekly|monthly|annual|every|first\s+\d+\s+weeks|then|ongoing)\b/.test(normalizedText) + ) { + return true; + } + return !/\b(?:monitor|monitoring|follow[-\s]?up|baseline|weekly|monthly|annual|every|level|levels|blood test|fbc|anc|wbc|ecg|lft|renal|thyroid|metabolic|glucose|bsl|lipids|cholesterol|triglycerides|blood pressure|bp|pulse|weight|bmi)\b/.test( + normalizedText, + ); + } + return false; +} + +const openingSentenceTerminatorPattern = /[.!?]["')\]]*(?:\s|$)/; +const incompleteOpeningSentencePattern = + /^(?:and|or|but|because|although|while|when|where|after|before|during|with|without|including|such as|then|to|recommended\s+over|alternative\s+agent|chart\s+reference|table\s+summari[sz]ing)\b/i; +const sourceHeadingOpeningPattern = + /^(?:appendix\s+\d+|dosage|dose|dosing|dosage and monitoring|dose table|monitoring|referral criteria|contraindications?|adverse effects?|required actions?|thresholds?|summary|overview|formulations?|available products?|product information|table|figure)\.?$/i; +const openingSentenceActionPattern = + /\b(?:avoid|arrange|be|can|cannot|cease|check|continue|could|discontinue|document|give|include|includes|included|increase|involves|is|list|lists|may|might|monitor|must|need|needed|needs|provide|provides|recommend|recommends|reduce|refer|repeat|report|required|requires|review|should|start|starts|stop|support|supports|use|uses|was|were|will|withhold|would)\b/i; + +function firstSentence(value: string) { + const normalized = normalizeSectionText(value); + const terminatorMatch = normalized.match(openingSentenceTerminatorPattern); + if (!terminatorMatch || terminatorMatch.index === undefined) return normalized; + return normalized.slice(0, terminatorMatch.index + terminatorMatch[0].trimEnd().length).trim(); +} + +function hasCompleteOpeningSentence(value: string) { + const normalized = normalizeSectionText(value); + if (!normalized || !openingSentenceTerminatorPattern.test(normalized)) return false; + const opening = firstSentence(normalized).replace(/\*\*/g, "").trim(); + const openingWithoutTerminal = opening.replace(/[.!?]["')\]]*$/, "").trim(); + if (opening.length < 18 || openingWithoutTerminal.length < 12) return false; + if (templateLikeGeneratedPrefixPattern.test(opening)) return false; + if (incompleteOpeningSentencePattern.test(opening)) return false; + if (sourceHeadingOpeningPattern.test(openingWithoutTerminal)) return false; + return openingSentenceActionPattern.test(opening); +} + +export function hasInvalidModelEvidenceIds(answer: Pick) { + return /\binvalid_model_citation_ids\b/.test(answer.routingReason ?? ""); +} + +export function generatedAnswerQualityFailureReason(answer: RagAnswer, query: string, queryClass: RagQueryClass) { + const cleanedAnswer = sanitizeAnswerText(answer.answer); + if (!cleanedAnswer) return "empty_after_sanitize"; + if (!hasCompleteOpeningSentence(cleanedAnswer)) return "incomplete_opening_sentence"; + if (hasBadFinalAnswerQuality(cleanedAnswer)) return "bad_final_answer_quality"; + if (hasClinicalAnswerQualityIssue(cleanedAnswer)) return "clinical_answer_quality_issue"; + if (isLowYieldClinicalText(cleanedAnswer)) return "low_yield_answer"; + if (isFragmentLikeClinicalAnswer(cleanedAnswer, query)) return "fragment_like_answer"; + if (isMissingCriticalQueryIntent(query, cleanedAnswer)) return "missing_query_intent"; + // Core-term (entity/intent) overlap responsiveness check. For extractive/low-confidence answers + // it always applies. For synthesized model answers it is only safe on narrow simple direct + // questions that are not bare definitions (yes/no, when/where, "does X…") — there a well-targeted + // answer genuinely should carry the query entity terms, and anaphora is rare. Broad/comparison/ + // summary answers legitimately paraphrase, so enforcing overlap there would reject good answers. + // A model-answer failure here only escalates fast→strong and is recovered for strongly + // source-backed answers, so the downside of enforcing it is a retry, not a wrongful gap. + const enforceModelAnswerOverlap = isSimpleDirectQuestion(query, queryClass) && !isBareDefinitionQuestion(query); + if ( + (answer.routingMode === "extractive" || answer.confidence === "low" || enforceModelAnswerOverlap) && + !hasRelevantQueryOverlap(cleanedAnswer, query) + ) { + return "missing_query_overlap"; + } + if (hasInvalidModelEvidenceIds(answer)) return "invalid_model_evidence_ids"; + if (isUnusableGeneratedAnswer(answer)) return "unusable_generated_answer"; + if (isTemplateLikeGeneratedAnswer(answer)) return "template_like_answer"; + if (isOverExpandedSimpleGeneratedAnswer(query, queryClass, answer)) return "overexpanded_simple_answer"; + return null; +} + +function finalQualityFailure(answer: RagAnswer, query: string, queryClass: RagQueryClass, reason: string): RagAnswer { + return { + ...answer, + answer: finalQualityGapAnswer(query, queryClass), + grounded: false, + confidence: "unsupported", + answerSections: [], + responseMode: "evidence_gap", + routingReason: [answer.routingReason, `final_quality_gate:${reason}`].filter(Boolean).join("; "), + }; +} + +function shouldPreserveSourceBackedGeneratedAnswer(answer: RagAnswer, reason: string) { + if (reason !== "missing_query_intent" && reason !== "missing_query_overlap") return false; + if (!answer.grounded || answer.confidence === "unsupported" || answer.citations.length === 0) return false; + if (hasInvalidModelEvidenceIds(answer)) return false; + + const sourceSelection = answer.smartApiPlan?.answerPlan.sourceSelection; + if (!sourceSelection?.selectedCount || !sourceSelection.requiredSignalsSatisfied) return false; + if (sourceSelection.missingRequiredSignals.length > 0) return false; + + const matchedSignals = sourceSelection.matchedSignals; + const hasSpecificSourceSignal = matchedSignals.some( + (signal) => + signal.startsWith("index_unit:") || + [ + "document_title", + "document_label", + "table_fact", + "source_image", + "visual_table", + "direct_relevance", + "active_community", + "ed", + "agitation", + "dose_amount", + "route", + "flowchart_or_pathway", + ].includes(signal), + ); + const hasStructuredChunk = + sourceSelection.topChunkTypes.table > 0 || + sourceSelection.topChunkTypes.flowchart > 0 || + sourceSelection.topChunkTypes.medication_chart > 0 || + sourceSelection.topChunkTypes.patient_education > 0; + + return hasSpecificSourceSignal || hasStructuredChunk; +} + +function sectionHeadingKind(heading: string): AnswerSectionKind { + if (/\b(?:dose|dosing|medication)\b/i.test(heading)) return "medication_dose"; + if (/\b(?:monitor|timing|baseline|follow)\b/i.test(heading)) return "monitoring_timing"; + if (/\b(?:threshold|red|amber|withhold|stop|cease)\b/i.test(heading)) return "thresholds"; + if (/\b(?:gap|unsupported|source)\b/i.test(heading)) return "source_gap"; + if (/\b(?:contraindicat|caution|avoid|risk)\b/i.test(heading)) return "contraindications_cautions"; + return "required_actions"; +} + +export function cleanAnswerSectionHeading(heading: string, body: string) { + const normalized = normalizeSectionText(heading); + if ( + !normalized || + /^(?:direct answer|bottom line|high-yield summary|source-backed answer|direct source-backed answer)$/i.test( + normalized, + ) + ) { + if (/\b(?:dose|mg|daily|tds|bd)\b/i.test(body)) return "Dose"; + if (/\b(?:monitor|baseline|fbc|anc|ecg|level)\b/i.test(body)) return "Monitoring"; + if (/\b(?:withhold|stop|cease|threshold|red|amber)\b/i.test(body)) return "Thresholds"; + if (/\b(?:gap|not enough|unsupported|insufficient)\b/i.test(body)) return "Source gap"; + return "Key point"; + } + return normalized; +} + +function applyProviderLabels(answer: RagAnswer): RagAnswer { + const inferredSourceOnlyFallback = + answer.routingMode === "extractive" || /(?:^|;\s*)generation_fallback(?::|$)/i.test(answer.routingReason ?? ""); + const answerQualityTier: RagAnswer["answerQualityTier"] = + answer.answerQualityTier ?? + (answer.modelUsed ? "model_synthesis" : inferredSourceOnlyFallback ? "source_only" : undefined); + const fallbackReason = + answer.fallbackReason ?? + (answerQualityTier === "source_only" ? (fallbackReasonFromRouting(answer.routingReason) ?? "source_only") : null); + const degradedActive = answerQualityTier === "source_only"; + return { + ...answer, + providerMode: answer.providerMode ?? ragProviderMode(), + answerQualityTier, + fallbackReason, + degradedMode: answer.degradedMode ?? { + active: degradedActive, + reason: degradedActive ? fallbackReason : null, + }, + }; +} + +// Public wrapper: runs quality finalization, then stamps provider/quality labels so the UI can +// disclose source-only (lower-quality) answers and verify-against-sources guidance. +export function finalizeRagAnswerQuality(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { + return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass)); +} + +function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { + // Deterministic, template-built answers (document-support lists, table/visual source + // references) are well-formed by construction and carry no free-text clinical claims. + // The clinical-prose sanitizer/quality gate below is designed for model prose and would + // strip their document names (facility codes like "(NOCC)(AKG)" read as non-prose), + // turning a valid answer into garble that then fails the gate. Return them untouched. + if (answer.preformatted && answer.grounded) { + return answer; + } + const cleanedAnswer = sanitizeAnswerText(answer.answer); + const gapLikeAnswer = + /could not find enough clean|no relevant clinical source|no current source|cannot provide a clinical answer|cannot provide a source-backed clinical answer|nearby indexed passages|not strong enough to support a reliable answer|no specific\b.*\bcan be confirmed|do not contain indexed guidance|do not contain (?:specific\s+)?information|do not provide specific|no\b.*\bguidance\b.*\bincluded|defer to other sources/i.test( + cleanedAnswer, + ); + const existingGapAnswer = + gapLikeAnswer && (!answer.grounded || answer.routingMode === "strong" || answer.confidence === "low"); + if (existingGapAnswer) { + const gapAnswer = finalQualityGapAnswer(query, queryClass); + return { + ...answer, + answer: gapAnswer, + grounded: false, + confidence: "unsupported", + answerSections: [], + responseMode: "evidence_gap", + }; + } + + if (!answer.grounded && answer.confidence === "unsupported") { + return finalQualityFailure(answer, query, queryClass, "ungrounded_unsupported_answer"); + } + + let qualityFailureReason = !cleanedAnswer + ? "empty_after_sanitize" + : cleanedAnswer.length < 18 + ? "answer_too_short" + : generatedAnswerQualityFailureReason(answer, query, queryClass); + + if (qualityFailureReason) { + if (shouldPreserveSourceBackedGeneratedAnswer(answer, qualityFailureReason)) { + answer = { + ...answer, + confidence: answer.confidence === "low" ? "medium" : answer.confidence, + routingReason: [answer.routingReason, `final_quality_gate_source_backed_recovery:${qualityFailureReason}`] + .filter(Boolean) + .join("; "), + }; + qualityFailureReason = null; + } else { + return finalQualityFailure(answer, query, queryClass, qualityFailureReason); + } + } + + const answerKey = normalizeSectionText(cleanedAnswer).toLowerCase(); + const answerSections = (answer.answerSections ?? []) + .map((section) => { + const body = sanitizeAnswerText(section.body); + if (!body || hasClinicalAnswerQualityIssue(body) || isLowYieldClinicalText(body)) return null; + const bodyKey = normalizeSectionText(body).toLowerCase(); + const isDocumentListSection = section.kind === "documentation" || /\bdocument matches\b/i.test(section.heading); + if ( + !isDocumentListSection && + (bodyKey === answerKey || answerKey.includes(bodyKey) || bodyKey.includes(answerKey)) + ) { + return null; + } + const heading = cleanAnswerSectionHeading(section.heading, body); + return { + ...section, + heading, + body: boldHighYieldClinicalText(body, query), + kind: section.kind ?? sectionHeadingKind(heading), + supportLevel: section.supportLevel ?? "direct", + } satisfies AnswerSection; + }) + .filter((section): section is Exclude => Boolean(section)); + + return applyNumericVerification({ + ...answer, + answer: boldHighYieldClinicalText(cleanedAnswer, query), + answerSections, + }); +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 74cb25a745..09ea8c66d2 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -2,18 +2,12 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope"; import { classifyCorpusGrounding } from "@/lib/corpus-grounding"; import type { Database, Json } from "@/lib/supabase/database.types"; -import { - embedTextWithTelemetry, - generateStructuredTextResult, - type OpenAIReasoningEffort, - type OpenAITextResult, -} from "@/lib/openai"; +import { embedTextWithTelemetry, generateStructuredTextResult, type OpenAITextResult } from "@/lib/openai"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON, allowsAutoDegrade, classifyProviderFailure, isSourceOnlyMode, - ragProviderMode, sourceOnlyReason, } from "@/lib/rag-provider"; import { allowedChunkMap, citationFromResult as resultCitation, compactCitations } from "@/lib/citations"; @@ -26,6 +20,28 @@ import { applyNumericVerification } from "@/lib/answer-verification"; export { applyNumericVerification, unboldUnverifiedNumbers } from "@/lib/answer-verification"; import { capPerDocumentCrowding, selectModelContextResults } from "@/lib/rag-context-selection"; export { capPerDocumentCrowding, selectModelContextResults } from "@/lib/rag-context-selection"; +import { + buildExtractiveAnswer, + cleanAnswerSectionHeading, + finalQualityGapAnswer, + finalizeRagAnswerQuality, + generatedAnswerQualityFailureReason, + hasInvalidModelEvidenceIds, + isOverExpandedSimpleGeneratedAnswer, + isSimpleDirectQuestion, + isTemplateLikeGeneratedAnswer, + isUnusableGeneratedAnswer, + sourceBackedGenerationTimeoutAnswer, + strongReasoningEffortForQueryClass, +} from "@/lib/rag-extractive-answer"; +export { + classifyAnswerIntent, + completeExtractiveSentence, + generatedAnswerQualityFailureReason, + isBareDefinitionQuestion, + sourceBackedGenerationTimeoutAnswer, + strongReasoningEffortForQueryClass, +} from "@/lib/rag-extractive-answer"; import { buildRagSourceBlock, compactContextText } from "@/lib/rag-source-block"; export { buildRagSourceBlock, truncateForModel } from "@/lib/rag-source-block"; import { extractNumericTokens, VERIFY_AGAINST_SOURCE_NOTE, verifyAnswerNumbers } from "@/lib/answer-verification"; @@ -65,7 +81,6 @@ import { fenceSourceEvidence, isLowYieldClinicalText, neutralizePromptInstructions, - sourceTextForClinicalProse, sourceTextForDisplay, sourceTextForModel, } from "@/lib/source-text-sanitizer"; @@ -111,7 +126,6 @@ import type { ChunkImage, ClinicalImageUseClass, Citation, - ConflictOrGap, ClinicalQueryAnalysis, CorpusGroundingVerdict, DocumentIndexQuality, @@ -120,7 +134,6 @@ import type { EvidenceRelevance, RelatedDocument, OpenAITokenUsage, - QuoteCard, RetrievalConfidenceGateStatus, RetrievalDiagnostics, RetrievalIntent, @@ -1024,7 +1037,7 @@ function hasOpenAIUsage(usage: OpenAITokenUsage) { return Object.values(usage).some((value) => typeof value === "number" && value > 0); } -function fallbackReasonFromRouting(reason?: string | null) { +export function fallbackReasonFromRouting(reason?: string | null) { if (!reason) return null; return ( reason @@ -2649,7 +2662,7 @@ async function searchDocumentLookupFastPath(args: { .slice(0, args.matchCount); } -function collectMemoryCards(results: SearchResult[], limit = 8) { +export function collectMemoryCards(results: SearchResult[], limit = 8) { const seen = new Set(); const cards: DocumentMemoryCard[] = []; for (const result of results) { @@ -2664,7 +2677,7 @@ function collectMemoryCards(results: SearchResult[], limit = 8) { return cards; } -function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemoryCard[]): DocumentIndexQuality { +export function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemoryCard[]): DocumentIndexQuality { const sourceMetadata = results.map((result) => normalizeSourceMetadata(result.source_metadata)); const indexedQualityRows = results .map((result) => result.indexing_quality) @@ -2708,7 +2721,10 @@ function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemo }; } -function buildAnswerScoreExplanations(results: SearchResult[], limit = 8): NonNullable { +export function buildAnswerScoreExplanations( + results: SearchResult[], + limit = 8, +): NonNullable { return results.slice(0, limit).map((result) => ({ chunk_id: result.id, document_id: result.document_id, @@ -3502,7 +3518,7 @@ function shouldReturnBeforeMemory( return !shouldUseMemoryBeforeFastPath(queryClass); } -function evidenceTextForGate(result: SearchResult) { +export function evidenceTextForGate(result: SearchResult) { const tableText = (result.table_facts ?? []) .map((fact) => [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action].join(" "), @@ -3939,7 +3955,7 @@ function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryCla return tokenHits * 0.08 + typeBoost + doseBoost + (card.confidence ?? 0) * 0.08 + lowValueTitlePenalty; } -function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, queryClass: RagQueryClass) { +export function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, queryClass: RagQueryClass) { return [...cards] .map((card, index) => ({ card, @@ -3951,1556 +3967,6 @@ function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, qu .map((item) => item.card); } -type AnswerIntent = - | "dose" - | "contraindication" - | "monitoring_schedule" - | "red_result_action" - | "document_lookup" - | "pathway_referral" - | "unsupported" - | "general"; - -type ExtractedClinicalFactKind = - | "bottom_line" - | "dose" - | "renal_limit" - | "monitoring" - | "threshold_action" - | "contraindication" - | "pathway_referral" - | "caveat"; - -type ExtractedClinicalFact = { - kind: ExtractedClinicalFactKind; - text: string; - citationChunkIds: string[]; - priority: number; -}; - -const extractiveLabelPattern = - /\b(?:Medication point|Table evidence|Threshold\/action|Risk\/escalation|Workflow step|Section summary|Source point|Dose detail|Monitoring)\s*:\s*/gi; - -function cleanExtractivePointText(value: string) { - return sourceTextForClinicalProse(value) - .replace(/\b(?:clinical_table|table_crop|diagram_crop)\b/gi, " ") - .replace( - /^(?:clinical\s+)?table\s+(?:showing|detailing|listing|outlining|describing)\b.*?:\s*(?=\b(?:if|when|for|cease|stop|withhold|contact|repeat|monitor|clozapine)\b)/i, - "", - ) - .replace( - /^[A-Z][A-Za-z /-]{3,80}:\s*(?=\b(?:if|when|for|cease|stop|withhold|contact|repeat|monitor|clozapine)\b)/, - "", - ) - .replace(extractiveLabelPattern, " ") - .replace(/^[\s\-•:]+/, "") - .replace(/^(?:monitoring|dose|dosing|source|section|table|guideline)\s*[.;:,-]\s*/i, "") - .replace(/([A-Za-z)])(\d{1,2})(?=(?:[,.;]|\s|$))/g, "$1") - .replace(/\s+[•]\s+/g, ". ") - .replace( - /\s+-\s+(?=[A-Z][a-z])|(?:\s+-\s*)?(?:Medication point|Table evidence|Threshold\/action|Risk\/escalation|Workflow step|Section summary|Source point)\s*:\s*/gi, - ". ", - ) - .replace(/\s+/g, " ") - .replace(/\s+([,.;:])/g, "$1") - .replace(/(?:\.\s*){2,}/g, ". ") - .trim(); -} - -const extractiveClinicalDirectivePattern = - /\b(?:arrange|assess|cease|check|complete|contact|continue|discontinue|discontinued|escalate|notify|prescribe|record|refer|report|review|stop|withhold|must|required|requires?|should)\b/i; -const extractiveQueryStopwords = new Set([ - "a", - "an", - "and", - "after", - "are", - "about", - "be", - "before", - "by", - "can", - "do", - "does", - "for", - "from", - "how", - "in", - "is", - "it", - "of", - "on", - "or", - "post", - "prior", - "the", - "to", - "what", - "when", - "where", - "which", - "who", - "why", - "should", - "dose", - "dosing", - "dosage", - "medication", - "medicine", - "monitoring", - "monitor", - "baseline", - "tests", - "result", - "results", - "pathway", - "referral", - "patient", - "patients", - "required", - "requires", - "clinical", - "advice", - "contraindication", - "contraindications", - "documents", - "document", - "support", - "supports", - "supported", - "sources", - "source", - "guidance", - "guideline", - "guidelines", - "please", -]); - -const answerIntentTerms = new Set([ - "action", - "actions", - "avoid", - "contraindicated", - "contraindication", - "contraindications", - "criteria", - "dose", - "doses", - "dosing", - "dosage", - "maximum", - "max", - "monitor", - "monitors", - "monitoring", - "pathway", - "refer", - "referral", - "renal", - "result", - "results", - "required", - "requires", - "schedule", - "threshold", - "thresholds", - "what", -]); - -export function classifyAnswerIntent(query: string, queryClass: RagQueryClass): AnswerIntent { - const normalized = normalizeSectionText(query).toLowerCase(); - if (!normalized) return "unsupported"; - if ( - /\b(?:what|which|list|show|find)\s+(?:documents?|sources?|guidelines?|files?)\b.*\b(?:support|cover|contain|for|about)\b/.test( - normalized, - ) || - /\b(?:documents?|sources?|guidelines?|files?)\s+(?:support|cover|contain|for|about)\b/.test(normalized) - ) { - return "document_lookup"; - } - if (/\b(?:contraindicat\w*|avoid|do not use|must not|should not|not use|opioid[-\s]?free)\b/.test(normalized)) { - return "contraindication"; - } - const hasResultActionSignal = - /\b(?:red|amber|green|anc|fbc|wbc|result|results|threshold|withhold|cease|stop|stopped|toxicity)\b/.test( - normalized, - ) || /\b(?:what\s+action|action\s+is\s+required|required\s+action|suspected\s+\w+\s+toxicity)\b/.test(normalized); - const hasScheduleSignal = /\b(?:monitor|monitoring|schedule|baseline|follow[-\s]?up|level|levels|test|tests)\b/.test( - normalized, - ); - // Toxicity and explicit action queries take priority over monitoring even if schedule/baseline/follow-up terms appear. - const hasStrongResultSignal = - /\b(?:toxicity|what\s+action|action\s+is\s+required|required\s+action|suspected\s+\w+\s+toxicity)\b/.test( - normalized, - ); - if ( - hasResultActionSignal && - (!/\b(?:schedule|baseline|follow[-\s]?up)\b/.test(normalized) || hasStrongResultSignal) - ) { - return "red_result_action"; - } - if (hasScheduleSignal) { - return "monitoring_schedule"; - } - if (hasResultActionSignal) return "red_result_action"; - if (/\b(?:dose|dosing|dosage|max(?:imum)?|mg|mcg|renal|eGFR|creatinine)\b/i.test(query)) return "dose"; - if (/\b(?:pathway|refer|referral|criteria|ect|electroconvulsive)\b/.test(normalized)) return "pathway_referral"; - if ( - queryClass === "document_lookup" || - /\b(?:find|show|open|which)\b.*\b(?:document|guideline|procedure|policy|protocol|form|source|file)\b/.test( - normalized, - ) || - /\b(?:documentation|forms?|documents?|sources?|guidelines?|procedure|policy|protocol)\b/.test(normalized) - ) { - return "document_lookup"; - } - if (queryClass === "unsupported_or_general" && !clinicalQuerySignalPattern.test(query)) return "unsupported"; - return "general"; -} - -function queryEntityTokens(query: string, intent: AnswerIntent) { - const tokens = extractiveQueryTokens(query).filter((token) => !answerIntentTerms.has(token)); - if (intent === "document_lookup") return tokens.filter((token) => token.length > 3); - return tokens; -} - -function uniqueAnswerTokens(tokens: string[]) { - return Array.from(new Set(tokens.filter(Boolean))); -} - -function queryIntentTokens(query: string, intent: AnswerIntent) { - const tokens = extractiveQueryTokens(query).filter((token) => answerIntentTerms.has(token)); - if (intent === "dose" && /\b(?:renal|egfr|creatinine|kidney)\b/i.test(query)) - return uniqueAnswerTokens(["renal", ...tokens]); - if (intent === "dose" && /\bmax(?:imum)?\b/i.test(query)) return uniqueAnswerTokens(["maximum", ...tokens]); - if (intent === "monitoring_schedule") return uniqueAnswerTokens(["monitoring", ...tokens]); - if (intent === "red_result_action") - return uniqueAnswerTokens(["red", "range", "blood", "result", "results", "threshold", "action", ...tokens]); - if (intent === "contraindication") return uniqueAnswerTokens(["contraindication", ...tokens]); - if (intent === "pathway_referral") return uniqueAnswerTokens(["referral", "criteria", ...tokens]); - return tokens; -} - -function answerIntentEvidencePattern(intent: AnswerIntent) { - switch (intent) { - case "dose": - return /\b(?:doses?|dosing|dosage|max(?:imum)?|mg|mcg|microgram|micrograms|mmol\/l|eGFR|renal|creatinine|daily|bd|tds|mane|nocte)\b/i; - case "contraindication": - return /\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/i; - case "monitoring_schedule": - return /\b(?:monitor|monitoring|baseline|weekly|monthly|annual|every|level|levels|blood test|fbc|anc|ecg|lft|renal|review)\b/i; - case "red_result_action": - return /\b(?:red|amber|green|threshold|withhold|cease|stop|discontinue|discontinued|urgent|contact|repeat|review|anc|fbc|wbc|neutrophil|toxic\w*|action|patholog\w*|haematolog\w*|hematolog\w*)\b/i; - case "pathway_referral": - return /\b(?:pathway|refer|referral|criteria|indicat\w*|ect|electroconvulsive|specialist|psychiat\w*)\b/i; - case "document_lookup": - return /\b(?:document|guideline|procedure|policy|protocol|form|source|file|support|supports|covers|contains)\b/i; - default: - return /\b(?:assess|arrange|check|continue|review|treat|manage|monitor|refer|dose|risk|therapy|diagnos\w*)\b/i; - } -} - -function resultCoversAnswerIntent(result: SearchResult, query: string, intent: AnswerIntent) { - if (intent === "unsupported") return false; - const text = evidenceTextForGate(result); - const entityTokens = queryEntityTokens(query, intent); - const intentTokens = queryIntentTokens(query, intent); - const entityCoverage = - entityTokens.length === 0 || - entityTokens.some((token) => queryTokenMatchesText(token, text)) || - (/\bect\b/i.test(query) && /\b(?:ect|electroconvulsive)\b/i.test(text)); - if (!entityCoverage) return false; - if (intent === "general") return true; - const intentCoverage = answerIntentEvidencePattern(intent).test(text); - if (!intentCoverage) return false; - if (intentTokens.length > 0 && !intentTokens.some((token) => queryTokenMatchesText(token, text))) return false; - if (/\brenal\b/i.test(query) && !/\b(?:renal|kidney|eGFR|creatinine)\b/i.test(text)) return false; - if (/\bmax(?:imum)?\b/i.test(query) && !/\b(?:max(?:imum)?|\d+(?:\.\d+)?\s?(?:mg|mcg))\b/i.test(text)) { - return false; - } - return true; -} -const extractiveTruncationPattern = - /\b(?:stabili[sz]e\s+the\s+do|the\s+do\b|liver\s+functi\b|respiratio\b|if\s+a\s+60%\s+decrease\s+in\s+b\b)\b/i; -const extractiveProductCataloguePattern = - /\b(?:Lithicarb|Quilonum\s+SR|Campral|imprest\s+location|formulary\s+one)\b|[®™]/i; -const extractiveStructuralArtifactPattern = - /\b(?:for\s+required,|monitoringup|prnselection|druguse|anddoses|reviewresponse|maximumrecommendeddoses|recommendeddoses|information:\s*review|links\s+to\s+relevant\s+documents\/resources|pharmacy\s+services\s+and\s+dispensing\s+protocol|role\s+responsibilities|document\s+control|straight\s+to\s+the\s+point\s+of\s+care|full\s+text|pubmed|randomi[sz]ed\s+clinical\s+trial|j\s+psychiatry|ann\s+emerg\s+med|site\s+map|gpo,\s+perth|tel:\s*\(|fax:\s*\()\b/i; -const extractiveHeadingOnlyPattern = - /^(?:dosage?|dosing|monitoring|baseline tests?|therapy|source|section|table|guideline|referral criteria|criteria)(?:\s*\([^)]{1,80}\))?\.?$/i; -const extractiveAllowedLowercaseStarterPattern = - /^(?:if|when|for|in|avoid|do|must|withhold|cease|stop|monitor|check|reduce|increase|adjust|start|commence|begin|use|target|baseline|serum|therapy|dosing|titrate|arrange|refer|review|prescribe|record|complete|continue|discontinue|escalate)\b/i; -const extractiveConcreteDosePattern = - /\b(?:\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms|mmol\/?l)|mmol\/l|daily|bd|tds|mane|nocte|target|range|serum|levels?|titration|titrate|titrated|adjust(?:ed|ment)?|dose\s+(?:adjust|reduc|increas)|reduce(?:d)?\s+doses?|doses?\s+(?:in|for|when|with|based|according)|max(?:imum)?|renal|eGFR|CrCl|creatinine|elderly|impairment|conventional tablets?)\b/i; -const extractiveMedicationEntityPattern = - /\b(?:acamprosate|aripiprazole|baclofen|citalopram|clozapine|diazepam|disulfiram|droperidol|escitalopram|fluoxetine|haloperidol|lithium|lorazepam|naltrexone|olanzapine|promethazine|quetiapine|risperidone|sertraline|valproate)\b/gi; - -function extractiveQueryTokens(query: string) { - return splitBalancedWords(query).filter((token) => token.length > 2 && !extractiveQueryStopwords.has(token)); -} - -function escapeQueryToken(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function queryTokenVariants(token: string) { - const variants = new Set([token]); - if (token.length > 5 && token.endsWith("ing")) variants.add(token.slice(0, -3)); - if (token.length > 4 && token.endsWith("ies")) variants.add(`${token.slice(0, -3)}y`); - if (token.length > 4 && token.endsWith("es")) variants.add(token.slice(0, -2)); - if (token.length > 4 && token.endsWith("s")) variants.add(token.slice(0, -1)); - return [...variants].filter((variant) => variant.length > 2); -} - -function queryTokenMatchesText(token: string, text: string) { - if (token === "ect") return /\b(?:ect|electroconvulsive)\b/i.test(text); - for (const variant of queryTokenVariants(token)) { - const pattern = - variant.length <= 3 - ? new RegExp(`\\b${escapeQueryToken(variant)}\\b`, "i") - : new RegExp(`\\b${escapeQueryToken(variant)}\\w*\\b`, "i"); - if (pattern.test(text)) return true; - } - return false; -} - -function medicationEntitiesInText(text: string) { - extractiveMedicationEntityPattern.lastIndex = 0; - return Array.from(new Set((text.match(extractiveMedicationEntityPattern) ?? []).map((match) => match.toLowerCase()))); -} - -function mentionsDifferentMedicationEntity(sentence: string, query: string) { - const queryMedicationEntities = medicationEntitiesInText(query); - if (!queryMedicationEntities.length) return false; - return medicationEntitiesInText(sentence).some((entity) => !queryMedicationEntities.includes(entity)); -} - -function hasRelevantQueryOverlap( - text: string, - query: string, - intent: AnswerIntent = classifyAnswerIntent(query, classifyRagQuery(query).queryClass), -) { - const tokens = extractiveQueryTokens(query); - if (!tokens.length) return true; - const normalized = normalizeSectionText(text).toLowerCase(); - const entityTokens = queryEntityTokens(query, intent); - const intentTokens = queryIntentTokens(query, intent); - const entityCovered = - entityTokens.length === 0 || - entityTokens.some((token) => queryTokenMatchesText(token, normalized)) || - (/\bect\b/i.test(query) && /\b(?:ect|electroconvulsive)\b/i.test(normalized)); - if (!entityCovered && intent !== "general") return false; - if (intent === "general" || intent === "unsupported") - return tokens.some((token) => queryTokenMatchesText(token, text)); - return ( - answerIntentEvidencePattern(intent).test(normalized) && - (intentTokens.length === 0 || intentTokens.some((token) => queryTokenMatchesText(token, normalized))) - ); -} - -function hasBadExtractiveQuality(text: string) { - const normalized = normalizeSectionText(text); - if (!normalized) return true; - if (extractiveTruncationPattern.test(normalized)) return true; - if (extractiveProductCataloguePattern.test(normalized)) return true; - if (extractiveStructuralArtifactPattern.test(normalized)) return true; - if (extractiveHeadingOnlyPattern.test(normalized.replace(/[.;]+$/, ""))) return true; - if (/^([A-Za-z][A-Za-z /-]{3,60})\s+\1\.?$/i.test(normalized)) return true; - const firstToken = normalized.split(/\s+/, 1)[0] ?? ""; - if ( - /^[a-z][a-z -]{2,}\b/.test(normalized) && - !extractiveAllowedLowercaseStarterPattern.test(normalized) && - !medicationEntitiesInText(firstToken).length - ) { - return true; - } - if (/\b[A-Za-z]{4,}[A-Z]{2,}[A-Za-z]{2,}\b/.test(normalized)) return true; - // Narrow consecutive-arrow check: only flag '>>' that is NOT a clinical comparator like 'QTc >500 ms'. - // Two adjacent > with only whitespace between them (not digits/letters) signals markup artifacts. - if (/>[\s]*>/.test(normalized) && !/\w\s*>\s*\d/.test(normalized)) return true; // consecutive >> arrows - if (/\w+\s*>\s*\w+\s*>\s*\w+/g.test(normalized) && !/\d\s*>\s*\d/.test(normalized)) return true; // breadcrumb trails like A > B > C (not numeric ranges) - if ( - /^\s*(?:references?(?!\s+(?:range|interval|value|level|limit|coordinate|check|system|dosing|monitoring|guideline))|bibliography)\b/i.test( - normalized, - ) - ) - return true; - if (hasClinicalAnswerQualityIssue(normalized)) return true; - if (/\btable\s+\d+\b/i.test(normalized) && normalized.length > 180) return true; - return false; -} - -/** - * Quality gate for *completed* answers at the final validation step. - * - * Unlike `hasBadExtractiveQuality`, this version skips `extractiveProductCataloguePattern` - * because final answers to brand, PBS/access, or product-form questions legitimately contain - * medication brand names (Campral, Lithicarb, Quilonum SR) and ®/™ symbols. - * Applying the catalogue filter here would incorrectly replace valid answers with source-gap - * responses for those question types. - */ -function hasBadFinalAnswerQuality(text: string) { - const normalized = normalizeSectionText(text); - if (!normalized) return true; - if (extractiveTruncationPattern.test(normalized)) return true; - // Note: extractiveProductCataloguePattern is intentionally excluded here — see JSDoc above. - if (extractiveStructuralArtifactPattern.test(normalized)) return true; - if (extractiveHeadingOnlyPattern.test(normalized.replace(/[.;]+$/, ""))) return true; - if (/^([A-Za-z][A-Za-z /-]{3,60})\s+\1\.?$/i.test(normalized)) return true; - const firstToken = normalized.split(/\s+/, 1)[0] ?? ""; - if ( - /^[a-z][a-z -]{2,}\b/.test(normalized) && - !extractiveAllowedLowercaseStarterPattern.test(normalized) && - !medicationEntitiesInText(firstToken).length - ) { - return true; - } - if (/\b[A-Za-z]{4,}[A-Z]{2,}[A-Za-z]{2,}\b/.test(normalized)) return true; - if (/>[\s]*>/.test(normalized) && !/\w\s*>\s*\d/.test(normalized)) return true; - if (/\w+\s*>\s*\w+\s*>\s*\w+/g.test(normalized) && !/\d\s*>\s*\d/.test(normalized)) return true; - if ( - /^\s*(?:references?(?!\s+(?:range|interval|value|level|limit|coordinate|check|system|dosing|monitoring|guideline))|bibliography)\b/i.test( - normalized, - ) - ) - return true; - if (hasClinicalAnswerQualityIssue(normalized)) return true; - if (/\btable\s+\d+\b/i.test(normalized) && normalized.length > 180) return true; - return false; -} - -function isLowValueExtractiveCaption(clause: string) { - const descriptor = - /^(?:clinical\s+table|table|figure|image)\s+(?:showing|detailing|listing|outlining|describing|with|of)\b/i.test( - clause, - ) || /\btable\s+(?:showing|detailing|listing|outlining|describing)\b/i.test(clause); - if (!descriptor) return false; - return !extractiveClinicalDirectivePattern.test(clause); -} - -function splitClinicalEvidenceSentences(value: string) { - return sourceTextForClinicalProse(value) - .split(/\r?\n+|(?<=[.!?])\s+|\s+[•]\s+|\s+\|\s+/) - .map(cleanExtractivePointText) - .filter( - (sentence) => - sentence.length >= 18 && - !looksLikeJsonArtifact(sentence) && - !isLowValueExtractiveCaption(sentence) && - !hasBadExtractiveQuality(sentence), - ); -} - -function factKindForSentence(sentence: string, query: string, intent: AnswerIntent): ExtractedClinicalFactKind | null { - const text = normalizeSectionText(sentence); - if (!text) return null; - if ( - /\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/i.test( - text, - ) - ) { - return "contraindication"; - } - if (/\b(?:renal|kidney|eGFR|creatinine|CrCl)\b/i.test(text)) return "renal_limit"; - if ( - /\b(?:red|amber|green|threshold|withhold|cease|stop|discontinue|discontinued|urgent|contact|repeat|anc|fbc|wbc|neutrophil|toxic\w*|action)\b/i.test( - text, - ) - ) { - return "threshold_action"; - } - if (/\b(?:pathway|refer|referral|criteria|indicat\w*|ect|electroconvulsive|specialist|psychiat\w*)\b/i.test(text)) { - return "pathway_referral"; - } - if ( - /\b(?:monitor|monitoring|baseline|weekly|monthly|annual|every|level|levels|blood test|ecg|lft|review)\b/i.test(text) - ) { - return "monitoring"; - } - if ( - /\b(?:doses?|dosing|dosage|max(?:imum)?|\d+(?:\.\d+)?\s?(?:mg|mcg)|daily|bd|tds|mane|nocte|mmol\/l)\b/i.test(text) - ) { - return "dose"; - } - if (/\b(?:caution|risk|adverse|side effect|limited|not enough|insufficient)\b/i.test(text)) return "caveat"; - if (intent === "general" && hasRelevantQueryOverlap(text, query, intent)) return "bottom_line"; - return null; -} - -function factSupportsAnswerIntent( - kind: ExtractedClinicalFactKind, - sentence: string, - query: string, - intent: AnswerIntent, -) { - const text = normalizeSectionText(sentence); - const normalizedQuery = normalizeSectionText(query).toLowerCase(); - if (!text || hasBadExtractiveQuality(text)) return false; - - switch (intent) { - case "dose": - if (kind !== "dose" && kind !== "renal_limit") { - // Allow contraindication facts when the query explicitly asks for renal information, - // since renal contraindications (e.g. creatinine >120 micromol/L: contraindicated) are - // essential dose safety facts for renal-dose queries. - if ( - kind === "contraindication" && - /\brenal\b/i.test(query) && - /\b(?:renal|kidney|eGFR|creatinine|CrCl)\b/i.test(text) - ) { - // fall through to dose text check below - } else { - return false; - } - } - if (/\brenal\b/i.test(query) && !/\b(?:renal|kidney|eGFR|creatinine|CrCl)\b/i.test(text)) return false; - if (/\bmax(?:imum)?\b/i.test(query) && !/\b(?:max(?:imum)?|\d+(?:\.\d+)?\s?(?:mg|mcg))\b/i.test(text)) { - return false; - } - return extractiveConcreteDosePattern.test(text); - case "contraindication": - return ( - kind === "contraindication" && - /\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/i.test( - text, - ) - ); - case "monitoring_schedule": - // Also allow renal_limit facts — sentences like 'baseline renal function then repeat periodically' - // are classified as renal_limit (renal check triggers before monitoring), but are directly relevant - // to monitoring schedule answers. - if (kind !== "monitoring" && kind !== "dose" && kind !== "renal_limit") return false; - return /\b(?:monitor|monitoring|follow[-\s]?up|baseline|weekly|monthly|annual|every|several\s+times\s+a\s+year|level|levels|blood test|fbc|anc|wbc|ecg|lft|renal|thyroid|metabolic|glucose|bsl|lipids|cholesterol|triglycerides|blood pressure|bp|pulse|weight|bmi|mmol\/l|range)\b/i.test( - text, - ); - case "red_result_action": - if (kind !== "threshold_action" && kind !== "caveat") return false; - return ( - /\b(?:withhold|cease|stop|discontinue|discontinued|contact|urgent|repeat|review|call for help|escalat\w*|monitor|toxicity|rash)\b/i.test( - text, - ) && - // Include green/neutrophil: valid clozapine result-action vocabulary the classifier accepts - /\b(?:red|amber|green|threshold|result|results|anc|fbc|wbc|neutrophil|toxicity|rash|reaction|blood|patholog\w*|haematolog\w*|hematolog\w*)\b/i.test( - text, - ) - ); - case "pathway_referral": - if (kind !== "pathway_referral") return false; - if (/\breferr?al|refer\b/i.test(query) && !/\b(?:refer|referral|form\s*1a)\b/i.test(text)) return false; - if (/\bdischarge\s+criteria\b/i.test(text) && !/\bdischarge\b/.test(normalizedQuery)) return false; - return /\b(?:pathway|procedure|refer|referral|criteria|indicat\w*|ect|electroconvulsive|specialist|psychiat\w*|step)\b/i.test( - text, - ); - case "document_lookup": - return /\b(?:document|guideline|procedure|policy|protocol|form|source|file|support|supports|covers|contains)\b/i.test( - text, - ); - case "unsupported": - return false; - case "general": - default: - if (/\b(?:references?|bibliography|full\s+text|pubmed|randomi[sz]ed\s+clinical\s+trial)\b/i.test(text)) { - return false; - } - if (/^what\s+is\b/i.test(query)) { - return /\b(?:is|are|means|defined|characteri[sz]ed|involves|refers\s+to)\b/i.test(text); - } - return /\b(?:assess|arrange|check|continue|review|treat|manage|monitor|refer|dose|risk|therapy|diagnos\w*)\b/i.test( - text, - ); - } -} - -function factSentenceMatchesQueryFromResult( - sentence: string, - result: SearchResult, - query: string, - intent: AnswerIntent, -) { - if (mentionsDifferentMedicationEntity(sentence, query)) return false; - if (hasRelevantQueryOverlap(sentence, query, intent)) return true; - if (intent === "general" || intent === "unsupported") return false; - - const resultText = evidenceTextForGate(result); - const entityTokens = queryEntityTokens(query, intent); - const entityCoveredByResult = - entityTokens.length === 0 || entityTokens.some((token) => queryTokenMatchesText(token, resultText)); - if (!entityCoveredByResult) return false; - - const normalized = normalizeSectionText(sentence).toLowerCase(); - const intentTokens = queryIntentTokens(query, intent); - const intentCovered = - intentTokens.length === 0 || - intentTokens.some((token) => queryTokenMatchesText(token, normalized)) || - (intent === "dose" && extractiveConcreteDosePattern.test(normalized)); - return answerIntentEvidencePattern(intent).test(normalized) && intentCovered; -} - -function factPriority(kind: ExtractedClinicalFactKind, intent: AnswerIntent) { - if (intent === "contraindication" && kind === "contraindication") return 9; - if (intent === "red_result_action" && kind === "threshold_action") return 9; - if (intent === "monitoring_schedule" && kind === "monitoring") return 9; - if (intent === "pathway_referral" && kind === "pathway_referral") return 9; - if (intent === "dose" && kind === "dose") return 9; - if (intent === "dose" && kind === "renal_limit") return 8; - if (kind === "bottom_line") return 5; - if (kind === "caveat") return 3; - return 6; -} - -function tableFactsToClinicalFacts(result: SearchResult, query: string, intent: AnswerIntent): ExtractedClinicalFact[] { - return (result.table_facts ?? []) - .map((fact) => { - const text = cleanExtractivePointText( - [fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action].filter(Boolean).join(": "), - ); - const kind = factKindForSentence(text, query, intent); - if (!text || !kind || !factSentenceMatchesQueryFromResult(text, result, query, intent)) return null; - if (!factSupportsAnswerIntent(kind, text, query, intent)) return null; - return { - kind, - text, - citationChunkIds: [result.id], - priority: factPriority(kind, intent) + 1, - } satisfies ExtractedClinicalFact; - }) - .filter((fact): fact is ExtractedClinicalFact => Boolean(fact)); -} - -function extractClinicalFactsFromResults(results: SearchResult[], query: string, intent: AnswerIntent, limit = 8) { - const seen = new Set(); - const facts: ExtractedClinicalFact[] = []; - const usableResults = results.filter((result) => resultCoversAnswerIntent(result, query, intent)); - - for (const result of usableResults) { - for (const fact of tableFactsToClinicalFacts(result, query, intent)) { - const key = `${fact.kind}:${normalizeSectionText(fact.text).toLowerCase().slice(0, 160)}`; - if (seen.has(key)) continue; - seen.add(key); - facts.push(fact); - } - - const text = [ - result.retrieval_synopsis, - result.section_heading, - result.content, - result.adjacent_context, - ...(result.memory_cards ?? []).map((card) => card.content), - ] - .filter(Boolean) - .join("\n"); - for (const sentence of splitClinicalEvidenceSentences(text)) { - if (!factSentenceMatchesQueryFromResult(sentence, result, query, intent)) continue; - const kind = factKindForSentence(sentence, query, intent); - if (!kind) continue; - if (!factSupportsAnswerIntent(kind, sentence, query, intent)) continue; - const cleaned = sentence.length <= 280 ? sentence : `${sentence.slice(0, 277).trim()}...`; - const key = `${kind}:${normalizeSectionText(cleaned).toLowerCase().slice(0, 160)}`; - if (seen.has(key)) continue; - seen.add(key); - facts.push({ - kind, - text: cleaned, - citationChunkIds: [result.id], - priority: factPriority(kind, intent) + Math.min(scoreValue(result), 1), - }); - if (facts.length >= limit) break; - } - if (facts.length >= limit) break; - } - - return facts.sort((a, b) => b.priority - a.priority || a.text.length - b.text.length).slice(0, limit); -} - -function sentenceFromFact(fact: ExtractedClinicalFact, query: string) { - const text = sanitizeAnswerText(cleanExtractivePointText(fact.text)).replace(/[.;,\s]+$/, ""); - if (!text) return ""; - const entity = queryEntityTokens(query, classifyAnswerIntent(query, classifyRagQuery(query).queryClass))[0]; - const needsEntityPrefix = - entity && - fact.kind !== "bottom_line" && - !queryTokenMatchesText(entity, text) && - !/^(?:for|in|when|if|avoid|do not|must not|withhold|cease|stop|monitor|check|refer|arrange)\b/i.test(text); - const sentence = needsEntityPrefix ? `For ${entity}, ${text.charAt(0).toLowerCase()}${text.slice(1)}` : text; - return completeExtractiveSentence(sentence, query); -} - -function lowerFirst(value: string) { - if (!value) return value; - return `${value.charAt(0).toLowerCase()}${value.slice(1)}`; -} - -function upperFirst(value: string) { - if (!value) return value; - return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; -} - -// A clinical action clause: an imperative/directive verb that turns a bare conditional -// ("if INR is high") into a complete, self-contained sentence ("if INR is high, withhold warfarin"). -const extractiveActionClausePattern = - /\b(?:withhold|cease|stop|discontinue|hold|monitor|check|repeat|review|refer|arrange|contact|escalate|seek|avoid|continue|commence|start|initiate|titrate|prescribe|administer|give|reduce|increase|document|consider|recheck|admit|transfer)\b/i; - -export function completeExtractiveSentence(value: string, query: string) { - const cleaned = sanitizeAnswerText(value) - .replace(/[.;,\s]+$/, "") - .trim(); - if (!cleaned) return ""; - - const sentence = `${cleaned}.`; - if (hasCompleteOpeningSentence(sentence) && !isFragmentLikeClinicalAnswer(sentence, query)) return sentence; - - if (/^(?:when|if|where|after|before|during)\b/i.test(cleaned)) { - // A conditional clause that already carries its own action ("if INR is high, withhold warfarin") - // is a complete, natural sentence — present it directly instead of the stock "The guidance is - // that…" lead-in. Only when the condition has no action of its own do we add the wrapper so the - // fragment reads as a full sentence. - const conditionalAsSentence = `${upperFirst(cleaned)}.`; - if ( - /,\s*\S/.test(cleaned) && - extractiveActionClausePattern.test(cleaned) && - !isFragmentLikeClinicalAnswer(conditionalAsSentence, query) - ) { - return conditionalAsSentence; - } - return `The guidance is that ${lowerFirst(cleaned)}.`; - } - - const withoutLeadingFragment = cleaned.replace(/^(?:and|or|but|with|without|including|such as|then)\s+/i, ""); - if (/^to\b/i.test(cleaned)) { - return `The guidance is ${lowerFirst(cleaned)}.`; - } - if (withoutLeadingFragment !== cleaned) { - return `The guidance includes ${lowerFirst(withoutLeadingFragment)}.`; - } - - return `The guidance is that ${lowerFirst(cleaned)}.`; -} - -function sectionForFactKind(kind: ExtractedClinicalFactKind): Pick { - switch (kind) { - case "dose": - return { heading: "Dose", kind: "medication_dose" }; - case "renal_limit": - return { heading: "Renal limits", kind: "contraindications_cautions" }; - case "monitoring": - return { heading: "Monitoring", kind: "monitoring_timing" }; - case "threshold_action": - return { heading: "Result action", kind: "thresholds" }; - case "contraindication": - return { heading: "Contraindications", kind: "contraindications_cautions" }; - case "pathway_referral": - return { heading: "Pathway/referral", kind: "required_actions" }; - case "caveat": - return { heading: "Caveat", kind: "source_gap" }; - default: - return { heading: "Bottom line", kind: "bottom_line" }; - } -} - -function buildFactSections(facts: ExtractedClinicalFact[], query: string) { - const grouped = new Map(); - for (const fact of facts) grouped.set(fact.kind, [...(grouped.get(fact.kind) ?? []), fact]); - return Array.from(grouped.entries()) - .slice(0, 4) - .map(([kind, group]) => { - const section = sectionForFactKind(kind); - const body = group - .slice(0, 2) - .map((fact) => sentenceFromFact(fact, query)) - .filter(Boolean) - .join(" "); - return { - heading: section.heading, - kind: section.kind, - supportLevel: "direct", - body: boldHighYieldClinicalText(body, query), - citation_chunk_ids: Array.from(new Set(group.flatMap((fact) => fact.citationChunkIds))), - } satisfies AnswerSection; - }) - .filter((section) => section.body && section.citation_chunk_ids.length > 0); -} - -function buildFactSynthesizedAnswer(args: { - query: string; - queryClass: RagQueryClass; - intent: AnswerIntent; - results: SearchResult[]; -}) { - const facts = extractClinicalFactsFromResults(args.results, args.query, args.intent); - if (!facts.length) { - if (sourceBackedDocumentFallbackIntent(args.query, args.queryClass, args.intent, args.results)) { - return buildDocumentSupportListAnswer({ query: args.query, results: args.results }); - } - const gapAnswer = finalQualityGapAnswer(args.query, args.queryClass, args.intent); - return { - answer: gapAnswer, - body: gapAnswer, - citationChunkIds: [] as string[], - answerSections: [] as AnswerSection[], - }; - } - - const leadFacts = facts.slice(0, args.intent === "dose" ? 2 : 1); - const answer = sanitizeAnswerText( - leadFacts - .map((fact) => sentenceFromFact(fact, args.query)) - .filter(Boolean) - .join(" "), - ); - const answerSections = buildFactSections(facts, args.query); - return { - answer: boldHighYieldClinicalText(answer, args.query), - body: boldHighYieldClinicalText(answer, args.query), - citationChunkIds: Array.from(new Set(facts.flatMap((fact) => fact.citationChunkIds))), - answerSections, - }; -} - -function sourceBackedDocumentFallbackIntent( - query: string, - queryClass: RagQueryClass, - intent: AnswerIntent, - results: SearchResult[], -) { - if (results.length === 0) return false; - const strongestScore = Math.max(...results.map(scoreValue)); - if (strongestScore < 0.45) return false; - const normalized = normalizeSectionText(query).toLowerCase(); - const sourceBackedProcedureQuery = - /\b(?:process|procedure|protocol|pathway|workflow|steps?|requirements?|criteria|guidance|document)\b/.test( - normalized, - ); - if (!sourceBackedProcedureQuery) return false; - return ( - intent === "document_lookup" || - intent === "pathway_referral" || - queryClass === "document_lookup" || - queryClass === "broad_summary" - ); -} - -function documentSupportListIntent(query: string, queryClass: RagQueryClass) { - return ( - classifyAnswerIntent(query, queryClass) === "document_lookup" && - /\b(?:support|supports|supporting|sources?|documents?|guidelines?)\b/i.test(query) - ); -} - -function tableOrVisualSourceLookupIntent(query: string, queryClass: RagQueryClass, answerIntent: AnswerIntent) { - if (queryClass === "table_threshold" || answerIntent === "dose" || answerIntent === "monitoring_schedule") - return false; - return ( - /\b(?:which|where|find|open|locate)\b.{0,120}\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( - query, - ) || - /\b(?:show|display)\s+(?:me\s+)?(?:the\s+)?(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( - query, - ) || - /\b(?:which|what)\b.{0,80}\b(?:source|document|guideline|file|pdf)\b.{0,80}\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( - query, - ) || - /\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b.{0,80}\b(?:cover|covers|contain|contains|list|lists|guidance)\b/i.test( - query, - ) - ); -} - -function sourceLookupLabel(result: SearchResult) { - const tableTitle = (result.table_facts ?? []) - .map((fact) => fact.table_title || fact.row_label) - .find((value): value is string => Boolean(value?.trim())); - const imageTitle = (result.images ?? []) - .map((image) => image.tableTitle || image.caption) - .find((value): value is string => Boolean(value?.trim())); - const rawLabel = tableTitle || imageTitle || result.section_heading || result.title || result.file_name; - return normalizeSectionText(rawLabel) - .replace(/([a-zA-Z])\(/g, "$1 (") - .replace(/\s{2,}/g, " ") - .trim(); -} - -function hasTableOrVisualLookupEvidence(result: SearchResult) { - return ( - (result.table_facts?.length ?? 0) > 0 || - (result.images ?? []).some((image) => - /\b(?:clinical_table|flowchart_algorithm|medication_chart|risk_matrix|table_crop|diagram_crop|page_region|embedded)\b/i.test( - `${image.image_type ?? ""} ${image.sourceKind ?? ""} ${image.source_kind ?? ""}`, - ), - ) || - /\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( - `${result.section_heading ?? ""} ${result.title ?? ""} ${result.file_name ?? ""}`, - ) - ); -} - -function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: SearchResult[] }) { - const source = args.results.find(hasTableOrVisualLookupEvidence) ?? args.results[0]; - if (!source) { - const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup"); - return { answer: gapAnswer, citationChunkIds: [] as string[], answerSections: [] as AnswerSection[] }; - } - - const label = sourceLookupLabel(source) || "the top matched source"; - const answer = `The relevant source is ${label}, which covers the requested table or visual guidance.`; - - return { - answer, - citationChunkIds: [source.id], - preformatted: true, - answerSections: [ - { - heading: "Source match", - kind: "documentation", - supportLevel: "direct", - body: `The source match is ${label}.`, - citation_chunk_ids: [source.id], - }, - ] satisfies AnswerSection[], - }; -} - -function buildDocumentSupportListAnswer(args: { query: string; results: SearchResult[] }) { - const documents = buildDocumentBreakdown(args.results, extractQuoteCards(args.results, args.query)).slice(0, 5); - if (!documents.length) { - const gapAnswer = finalQualityGapAnswer(args.query, "document_lookup", "document_lookup"); - return { answer: gapAnswer, citationChunkIds: [] as string[], answerSections: [] as AnswerSection[] }; - } - const names = documents - .map((document) => - normalizeSectionText(document.title || document.file_name) - .replace(/([a-zA-Z])\(/g, "$1 (") - .replace(/\s{2,}/g, " ") - .trim(), - ) - .filter(Boolean); - const answer = - names.length === 1 - ? `I found one indexed document that supports this query: ${names[0]}.` - : `I found ${names.length} indexed documents that support this query: ${names.slice(0, -1).join("; ")}; and ${names.at(-1)}.`; - return { - answer, - preformatted: true, - citationChunkIds: Array.from( - new Set( - documents.flatMap((document) => - args.results - .filter((result) => result.document_id === document.document_id) - .slice(0, 1) - .map((result) => result.id), - ), - ), - ), - answerSections: [ - { - heading: "Document matches", - kind: "documentation", - supportLevel: "direct", - body: names.join("; "), - citation_chunk_ids: Array.from( - new Set( - documents.flatMap((document) => - args.results - .filter((result) => result.document_id === document.document_id) - .slice(0, 1) - .map((result) => result.id), - ), - ), - ), - }, - ] satisfies AnswerSection[], - }; -} - -function buildExtractiveAnswer(args: { - query: string; - queryClass: RagQueryClass; - results: SearchResult[]; - quoteCards: QuoteCard[]; - documentBreakdown: RagAnswer["documentBreakdown"]; - evidenceSummary: RagAnswer["evidenceSummary"]; - sourceCoverage: RagAnswer["sourceCoverage"]; - conflictsOrGaps: ConflictOrGap[]; - visualEvidence: RagAnswer["visualEvidence"]; - bestSource: RagAnswer["bestSource"]; - smartPanel: RagAnswer["smartPanel"]; - relatedDocuments: RagAnswer["relatedDocuments"]; - routeReason: string; - timings: RagAnswer["latencyTimings"]; -}) { - const quoteCards = args.quoteCards.length - ? args.quoteCards.slice(0, 5) - : extractQuoteCards(args.results, args.query, 5); - const memoryCards = rankMemoryCardsForAnswer(collectMemoryCards(args.results, 16), args.query, args.queryClass).slice( - 0, - 10, - ); - const citations = compactCitations(args.results).slice(0, Math.max(quoteCards.length, 1)); - const citationIds = new Set(citations.map((citation) => citation.chunk_id)); - const resultById = new Map(args.results.map((result) => [result.id, result])); - for (const card of memoryCards) { - for (const chunkId of card.source_chunk_ids ?? []) { - if (citationIds.has(chunkId)) continue; - const source = resultById.get(chunkId); - if (!source) continue; - citations.push(resultCitation(source)); - citationIds.add(chunkId); - } - } - for (const quote of quoteCards) { - if (!citationIds.has(quote.chunk_id)) { - // Guard the lookup: a quote card whose chunk_id was filtered out of results - // would make find() return undefined and resultCitation(undefined) throw. - const source = args.results.find((result) => result.id === quote.chunk_id); - if (source) citations.push(resultCitation(source)); - } - citationIds.add(quote.chunk_id); - } - - const answerIntent = classifyAnswerIntent(args.query, args.queryClass); - const naturalAnswer = documentSupportListIntent(args.query, args.queryClass) - ? buildDocumentSupportListAnswer({ query: args.query, results: args.results }) - : tableOrVisualSourceLookupIntent(args.query, args.queryClass, answerIntent) - ? buildTableOrVisualSourceLookupAnswer({ query: args.query, results: args.results }) - : buildFactSynthesizedAnswer({ - query: args.query, - queryClass: args.queryClass, - intent: answerIntent, - results: args.results, - }); - - // Fact synthesis is the production extractive path. If no clean fact survives - // coverage and artifact gates, fail closed instead of stitching snippets. - const hasExtractedAnswer = naturalAnswer.citationChunkIds.length > 0; - - // Ensure any chunk IDs referenced by the synthesized answer are present in citations, - // even if they were not in the top-ranked compactCitations slice. - for (const chunkId of naturalAnswer.citationChunkIds) { - if (!citationIds.has(chunkId)) { - const source = args.results.find((result) => result.id === chunkId); - if (source) { - citations.push(resultCitation(source)); - citationIds.add(chunkId); - } - } - } - - return { - answer: naturalAnswer.answer, - grounded: hasExtractedAnswer && citations.length > 0, - confidence: hasExtractedAnswer ? deriveConfidence(args.results, citations) : "unsupported", - citations: citations.slice(0, 5), - sources: args.results, - modelUsed: null, - routingMode: "extractive", - preformatted: hasExtractedAnswer && Boolean((naturalAnswer as { preformatted?: boolean }).preformatted), - routingReason: args.routeReason, - queryClass: args.queryClass, - latencyTimings: args.timings, - answerSections: naturalAnswer.answerSections ?? [], - quoteCards, - visualEvidence: args.visualEvidence, - bestSource: args.bestSource, - documentBreakdown: args.documentBreakdown, - evidenceSummary: args.evidenceSummary, - sourceCoverage: args.sourceCoverage, - conflictsOrGaps: args.conflictsOrGaps, - smartPanel: args.smartPanel, - relatedDocuments: args.relatedDocuments, - memoryCardsUsed: memoryCards, - indexingVersion: ragDeepMemoryVersion, - indexingQuality: buildIndexingQuality(args.results, memoryCards), - scoreExplanations: buildAnswerScoreExplanations(args.results), - } satisfies RagAnswer; -} - -function sourceBackedFallbackSubject(query: string) { - const normalized = normalizeSectionText(query) - .replace(/[?!.]+$/, "") - .trim(); - const subject = normalized - .replace(/^summari[sz]e\s+(?:the\s+)?/i, "") - .replace(/^what\s+(?:is|are)\s+(?:the\s+)?(?:process|requirements?)\s+for\s+/i, "") - .replace(/^what\s+(?:is|are)\s+required\s+(?:for|when)\s+/i, "") - .replace(/^what\s+(.+?)\s+is\s+required$/i, "$1") - .replace(/^what\s+does\s+(?:the\s+)?/i, "") - .replace(/\s+(?:document|procedure|guideline)\s+require$/i, "") - .replace(/^how\s+(?:is|are)\s+/i, "") - .replace(/\s+managed$/i, " management") - .trim(); - - if (subject.length < 4) return "this clinical question"; - return subject.length > 90 ? `${subject.slice(0, 87).trim()}...` : lowerFirst(subject); -} - -export function sourceBackedGenerationTimeoutAnswer(query: string) { - const subject = sourceBackedFallbackSubject(query); - return `The uploaded documents contain relevant guidance on ${subject}, but a full written answer could not be completed just now. The key source passages are cited below — please review them directly.`; -} - -const reasoningEffortRank: Record = { - none: 0, - low: 1, - medium: 2, - high: 3, - xhigh: 4, -}; - -// Strong-route reasoning effort by query class (P6). Safety-critical numeric/threshold classes keep -// the full configured effort; routine retrieval classes are capped at "medium" so high-effort -// reasoning over verbose context does not overrun the answer timeout and fail-closed on queries that -// actually have good sources. Never raises effort above the configured value. -export function strongReasoningEffortForQueryClass( - queryClass: RagQueryClass, - configured: OpenAIReasoningEffort, -): OpenAIReasoningEffort { - const safetyCritical = queryClass === "medication_dose_risk" || queryClass === "table_threshold"; - if (safetyCritical) return configured; - return reasoningEffortRank[configured] > reasoningEffortRank.medium ? "medium" : configured; -} - -function isUnusableGeneratedAnswer(answer: Pick) { - const normalized = normalizeSectionText(answer.answer ?? ""); - if (!normalized) return true; - if (normalized === machineReadableFallbackAnswer) return true; - if (answer.routingReason === "structured_parse_fallback") return true; - return looksLikeJsonArtifact(normalized); -} - -const templateLikeGeneratedTextPattern = - /\b(?:the\s+(?:strongest\s+)?retrieved\s+(?:source|sources|passages|excerpts)\s+(?:support|supports|show|shows|indicate|indicates)|retrieved\s+(?:source|sources|passages|excerpts)|source-backed|based\s+on\s+(?:the\s+)?(?:provided\s+)?(?:sources|excerpts|passages|retrieved\s+sources)|the\s+(?:cited\s+)?source\s+(?:states|supports|says|indicates)|provided\s+excerpts)\b/i; -const templateLikeGeneratedPrefixPattern = /^(?:answer|summary|bottom line|required actions|direct answer)\s*[:.-]\s+/i; -const templateLikeGeneratedSectionHeadingPattern = - /^(?:direct answer|bottom line|high-yield summary|source-backed answer|direct source-backed answer)$/i; -const simpleDirectQuestionPattern = - /^(?:what\s+(?:is|are)|what's|define|who\s+(?:is|are)|when\s+(?:is|are)|where\s+(?:is|are)|is\s+|are\s+|does\s+|do\s+)/i; -const simpleQuestionExpansionPattern = - /\b(?:management|manage|managed|treatment|treat|therapy|care|approach|pathway|dose|dosing|threshold|compare|versus|vs|monitoring|required|requirements|risk|side effect|contraindicat\w*|urgent|escalat\w*)\b/i; - -function isTemplateLikeGeneratedAnswer(answer: Pick) { - const answerText = normalizeSectionText(answer.answer ?? ""); - if ( - answerText && - (templateLikeGeneratedTextPattern.test(answerText) || templateLikeGeneratedPrefixPattern.test(answerText)) - ) { - return true; - } - - return (answer.answerSections ?? []).some((section) => { - const heading = normalizeSectionText(section.heading ?? ""); - const body = normalizeSectionText(section.body ?? ""); - return ( - (heading && templateLikeGeneratedSectionHeadingPattern.test(heading)) || - (body && (templateLikeGeneratedTextPattern.test(body) || templateLikeGeneratedPrefixPattern.test(body))) - ); - }); -} - -function isSimpleDirectQuestion(query: string, queryClass: RagQueryClass) { - const normalized = normalizeSectionText(query); - if (!normalized || normalized.length > 100) return false; - if (queryClass === "comparison" || queryClass === "table_threshold" || queryClass === "medication_dose_risk") { - return false; - } - if (queryClass === "broad_summary" || queryClass === "document_lookup") return false; - return simpleDirectQuestionPattern.test(normalized) && !simpleQuestionExpansionPattern.test(normalized); -} - -// Bare definitional questions ("what is X", "define X", "who is X") legitimately get short answers -// that refer back to the subject with anaphora ("It is …") without repeating the entity term, so -// the lexical entity-overlap responsiveness check would false-fire on them. Detect and exempt them -// when extending the overlap gate to synthesized model answers. -export function isBareDefinitionQuestion(query: string) { - return /^(?:what(?:'s| is| are)|define|who\s+(?:is|are))\b/i.test(normalizeSectionText(query)); -} - -function wordCount(value: string) { - return normalizeSectionText(value).split(/\s+/).filter(Boolean).length; -} - -function isOverExpandedSimpleGeneratedAnswer( - query: string, - queryClass: RagQueryClass, - answer: Pick, -) { - if (!isSimpleDirectQuestion(query, queryClass)) return false; - const sections = answer.answerSections ?? []; - const nonEssentialSectionCount = sections.filter((section) => !isEssentialSimpleQuestionSection(section)).length; - return nonEssentialSectionCount > 0 || sections.length > 1 || wordCount(answer.answer ?? "") > 95; -} - -function isEssentialSimpleQuestionSection(section: Pick) { - return /\b(?:gap|not enough|insufficient|unsupported|urgent|escalat\w*|risk|safety)\b/i.test( - `${section.heading} ${section.body}`, - ); -} - -const clinicalQuerySignalPattern = - /\b(?:lithium|clozapine|acamprosate|naltrexone|sertraline|valproate|antipsychotic|ect|bulimia|anorexia|eating disorder|dose|renal|pregnan|monitor|fbc|anc|qtc|opioid|contraindicat|referral|pathway|patient|clinical|guideline|medication|medicine|prescrib|therapy|treatment)\b/i; - -function isClearlyNonClinicalUnsupportedQuery(query: string) { - return ( - /\b(?:coffee|machine|parking|payroll|roster|leave|wifi|printer|canteen|expense|timesheet|room\s+booking|building|staff\s+room)\b/i.test( - query, - ) && !clinicalQuerySignalPattern.test(query) - ); -} - -function finalQualityGapAnswer( - query: string, - queryClass: RagQueryClass, - intent: AnswerIntent = classifyAnswerIntent(query, queryClass), -) { - if ( - isClearlyNonClinicalUnsupportedQuery(query) || - (queryClass === "unsupported_or_general" && !clinicalQuerySignalPattern.test(query)) - ) { - return "No relevant clinical source was found for this query."; - } - if (intent === "document_lookup") return "No current indexed document directly supporting this request was found."; - if (intent === "pathway_referral" && /\bect\b/i.test(query)) { - return "No current source with ECT referral criteria was found."; - } - if (intent === "pathway_referral") return "No current source with referral or pathway criteria was found."; - if (intent === "contraindication") return "No current source with contraindication or avoid-use guidance was found."; - if (intent === "monitoring_schedule") - return "No current source with monitoring timing or schedule guidance was found."; - if (intent === "red_result_action") { - if (/\bqtc\b/i.test(query)) return "No current source with QTc threshold or ECG action guidance was found."; - if (/\btoxicity\b/i.test(query)) return "No current source with toxicity action guidance was found."; - if (/\brash\b/i.test(query)) return "No current source with rash action guidance was found."; - return "No current source with threshold-specific action guidance was found."; - } - if (intent === "dose") { - if (/\brenal\b/i.test(query)) return "No current source with renal dosing limits for this query was found."; - return "No current source with dose guidance for this query was found."; - } - return "No current source with directly relevant clinical guidance was found."; -} - -function isFragmentLikeClinicalAnswer(text: string, query: string) { - const normalized = normalizeSectionText(text); - const lower = normalized.toLowerCase(); - if ( - /\b(?:dosing\s+frequencies\s+outside|prn\s+dose\s+daily\s+dose|table\s+summari[sz]ing|includes\s+risk\s+monitoring\s+form|recommended\s+over\s+>\d+\s*kg)\b/i.test( - normalized, - ) - ) { - return true; - } - if (/^for\s+(?:after|before|prior|post),/i.test(normalized)) return true; - if (/\bis\s+to:\.?$/i.test(normalized)) return true; - if ( - /^what\s+is\b/i.test(query) && - // Only apply this fragment gate for general/definition questions, not for clinical intent - // queries like "What is the maximum dose?" or "What is the QTc threshold?" which produce - // valid concise fact answers that don't contain definition-style phrasing. - !/\b(?:required|requirements?|dose|dosage|dosing|max(?:imum)?|mg|mcg|threshold|monitor|renal|contraindicat|referral|pathway|procedure|process|protocol|workflow|steps?|ect|electroconvulsive|qtc|fbc|anc|wbc|level|levels)\b/i.test( - query, - ) && - // "What is required/needed/involved/included…" and "what is the process/procedure/protocol…" - // are procedural questions, not definitions — their answers (and the source-pointer fallback) - // legitimately lack "X is a/an…" definition phrasing, so the definition-fragment gate must not - // fire for them (it otherwise fails good answers closed on a false positive — see P6). - !/^what\s+is\s+(?:required|needed|involved|included|expected|recommended|considered|the\s+(?:process|procedure|protocol|criteria|requirement|approach|guidance|recommendation|role|purpose|aim))\b/i.test( - query, - ) && - !/\b(?:is|are)\s+(?:a|an|the)\b|\b(?:defined\s+as|characteri[sz]ed\s+by|involves|refers\s+to|is\s+an?\s+eating\s+disorder)\b/i.test( - normalized, - ) - ) { - return true; - } - if (/\bbaby\s+whilst\b.*\bpost\s+anaesthetic\b/i.test(normalized)) return true; - if (/^(\*{0,2}[a-z][a-z0-9 -]{2,}\*{0,2})\s*:\s*\1\b/i.test(normalized)) return true; - if (/\?\s+(?:monitoring|adverse effects|when prescribed|prescribed for)\b/i.test(normalized)) return true; - if (/\bmonitoring adverse effects when prescribed\b/i.test(normalized)) return true; - if (/\b(?:after starting|ongoing)\s+\*{0,2}[a-z]+\*{0,2}\.?$/i.test(normalized) && normalized.length < 90) { - return true; - } - if (/\bect\b/i.test(query) && !/\b(?:ect|electroconvulsive|refer|referral)\b/i.test(lower)) return true; - return false; -} - -function isMissingCriticalQueryIntent(query: string, text: string) { - const normalizedQuery = normalizeSectionText(query).toLowerCase(); - const normalizedText = normalizeSectionText(text).toLowerCase(); - if (/\bcontraindicat\w*\b/.test(normalizedQuery)) { - return !/\b(?:contraindicat\w*|avoid|must not|do not|should not|not use|opioid[-\s]?free|withdrawal|precipitat\w*)\b/.test( - normalizedText, - ); - } - if (/\b(?:what to do|red|amber|anc|result|results)\b/.test(normalizedQuery)) { - return !/\b(?:withhold|cease|stop|discontinue|discontinued|contact|urgent|repeat|review|monitor|range|threshold|blood|patholog\w*|haematolog\w*|hematolog\w*|anc)\b/.test( - normalizedText, - ); - } - if (/\b(?:referral|refer|pathway)\b/.test(normalizedQuery) && /\bect\b/.test(normalizedQuery)) { - return !/\b(?:ect|electroconvulsive|refer|referral|criteria|indicat\w*|psychiat\w*)\b/.test(normalizedText); - } - if (/\b(?:monitor|monitoring|schedule|baseline|follow[-\s]?up)\b/.test(normalizedQuery)) { - if (/\bfbc\b/.test(normalizedQuery) && !/\bfbc\b/.test(normalizedText)) return true; - if (/\banc\b/.test(normalizedQuery) && !/\banc\b/.test(normalizedText)) return true; - if ( - /\bschedule\b/.test(normalizedQuery) && - !/\b(?:schedule|baseline|weekly|monthly|annual|every|first\s+\d+\s+weeks|then|ongoing)\b/.test(normalizedText) - ) { - return true; - } - return !/\b(?:monitor|monitoring|follow[-\s]?up|baseline|weekly|monthly|annual|every|level|levels|blood test|fbc|anc|wbc|ecg|lft|renal|thyroid|metabolic|glucose|bsl|lipids|cholesterol|triglycerides|blood pressure|bp|pulse|weight|bmi)\b/.test( - normalizedText, - ); - } - return false; -} - -const openingSentenceTerminatorPattern = /[.!?]["')\]]*(?:\s|$)/; -const incompleteOpeningSentencePattern = - /^(?:and|or|but|because|although|while|when|where|after|before|during|with|without|including|such as|then|to|recommended\s+over|alternative\s+agent|chart\s+reference|table\s+summari[sz]ing)\b/i; -const sourceHeadingOpeningPattern = - /^(?:appendix\s+\d+|dosage|dose|dosing|dosage and monitoring|dose table|monitoring|referral criteria|contraindications?|adverse effects?|required actions?|thresholds?|summary|overview|formulations?|available products?|product information|table|figure)\.?$/i; -const openingSentenceActionPattern = - /\b(?:avoid|arrange|be|can|cannot|cease|check|continue|could|discontinue|document|give|include|includes|included|increase|involves|is|list|lists|may|might|monitor|must|need|needed|needs|provide|provides|recommend|recommends|reduce|refer|repeat|report|required|requires|review|should|start|starts|stop|support|supports|use|uses|was|were|will|withhold|would)\b/i; - -function firstSentence(value: string) { - const normalized = normalizeSectionText(value); - const terminatorMatch = normalized.match(openingSentenceTerminatorPattern); - if (!terminatorMatch || terminatorMatch.index === undefined) return normalized; - return normalized.slice(0, terminatorMatch.index + terminatorMatch[0].trimEnd().length).trim(); -} - -function hasCompleteOpeningSentence(value: string) { - const normalized = normalizeSectionText(value); - if (!normalized || !openingSentenceTerminatorPattern.test(normalized)) return false; - const opening = firstSentence(normalized).replace(/\*\*/g, "").trim(); - const openingWithoutTerminal = opening.replace(/[.!?]["')\]]*$/, "").trim(); - if (opening.length < 18 || openingWithoutTerminal.length < 12) return false; - if (templateLikeGeneratedPrefixPattern.test(opening)) return false; - if (incompleteOpeningSentencePattern.test(opening)) return false; - if (sourceHeadingOpeningPattern.test(openingWithoutTerminal)) return false; - return openingSentenceActionPattern.test(opening); -} - -function hasInvalidModelEvidenceIds(answer: Pick) { - return /\binvalid_model_citation_ids\b/.test(answer.routingReason ?? ""); -} - -export function generatedAnswerQualityFailureReason(answer: RagAnswer, query: string, queryClass: RagQueryClass) { - const cleanedAnswer = sanitizeAnswerText(answer.answer); - if (!cleanedAnswer) return "empty_after_sanitize"; - if (!hasCompleteOpeningSentence(cleanedAnswer)) return "incomplete_opening_sentence"; - if (hasBadFinalAnswerQuality(cleanedAnswer)) return "bad_final_answer_quality"; - if (hasClinicalAnswerQualityIssue(cleanedAnswer)) return "clinical_answer_quality_issue"; - if (isLowYieldClinicalText(cleanedAnswer)) return "low_yield_answer"; - if (isFragmentLikeClinicalAnswer(cleanedAnswer, query)) return "fragment_like_answer"; - if (isMissingCriticalQueryIntent(query, cleanedAnswer)) return "missing_query_intent"; - // Core-term (entity/intent) overlap responsiveness check. For extractive/low-confidence answers - // it always applies. For synthesized model answers it is only safe on narrow simple direct - // questions that are not bare definitions (yes/no, when/where, "does X…") — there a well-targeted - // answer genuinely should carry the query entity terms, and anaphora is rare. Broad/comparison/ - // summary answers legitimately paraphrase, so enforcing overlap there would reject good answers. - // A model-answer failure here only escalates fast→strong and is recovered for strongly - // source-backed answers, so the downside of enforcing it is a retry, not a wrongful gap. - const enforceModelAnswerOverlap = isSimpleDirectQuestion(query, queryClass) && !isBareDefinitionQuestion(query); - if ( - (answer.routingMode === "extractive" || answer.confidence === "low" || enforceModelAnswerOverlap) && - !hasRelevantQueryOverlap(cleanedAnswer, query) - ) { - return "missing_query_overlap"; - } - if (hasInvalidModelEvidenceIds(answer)) return "invalid_model_evidence_ids"; - if (isUnusableGeneratedAnswer(answer)) return "unusable_generated_answer"; - if (isTemplateLikeGeneratedAnswer(answer)) return "template_like_answer"; - if (isOverExpandedSimpleGeneratedAnswer(query, queryClass, answer)) return "overexpanded_simple_answer"; - return null; -} - -function finalQualityFailure(answer: RagAnswer, query: string, queryClass: RagQueryClass, reason: string): RagAnswer { - return { - ...answer, - answer: finalQualityGapAnswer(query, queryClass), - grounded: false, - confidence: "unsupported", - answerSections: [], - responseMode: "evidence_gap", - routingReason: [answer.routingReason, `final_quality_gate:${reason}`].filter(Boolean).join("; "), - }; -} - -function shouldPreserveSourceBackedGeneratedAnswer(answer: RagAnswer, reason: string) { - if (reason !== "missing_query_intent" && reason !== "missing_query_overlap") return false; - if (!answer.grounded || answer.confidence === "unsupported" || answer.citations.length === 0) return false; - if (hasInvalidModelEvidenceIds(answer)) return false; - - const sourceSelection = answer.smartApiPlan?.answerPlan.sourceSelection; - if (!sourceSelection?.selectedCount || !sourceSelection.requiredSignalsSatisfied) return false; - if (sourceSelection.missingRequiredSignals.length > 0) return false; - - const matchedSignals = sourceSelection.matchedSignals; - const hasSpecificSourceSignal = matchedSignals.some( - (signal) => - signal.startsWith("index_unit:") || - [ - "document_title", - "document_label", - "table_fact", - "source_image", - "visual_table", - "direct_relevance", - "active_community", - "ed", - "agitation", - "dose_amount", - "route", - "flowchart_or_pathway", - ].includes(signal), - ); - const hasStructuredChunk = - sourceSelection.topChunkTypes.table > 0 || - sourceSelection.topChunkTypes.flowchart > 0 || - sourceSelection.topChunkTypes.medication_chart > 0 || - sourceSelection.topChunkTypes.patient_education > 0; - - return hasSpecificSourceSignal || hasStructuredChunk; -} - -function sectionHeadingKind(heading: string): AnswerSectionKind { - if (/\b(?:dose|dosing|medication)\b/i.test(heading)) return "medication_dose"; - if (/\b(?:monitor|timing|baseline|follow)\b/i.test(heading)) return "monitoring_timing"; - if (/\b(?:threshold|red|amber|withhold|stop|cease)\b/i.test(heading)) return "thresholds"; - if (/\b(?:gap|unsupported|source)\b/i.test(heading)) return "source_gap"; - if (/\b(?:contraindicat|caution|avoid|risk)\b/i.test(heading)) return "contraindications_cautions"; - return "required_actions"; -} - -function cleanAnswerSectionHeading(heading: string, body: string) { - const normalized = normalizeSectionText(heading); - if ( - !normalized || - /^(?:direct answer|bottom line|high-yield summary|source-backed answer|direct source-backed answer)$/i.test( - normalized, - ) - ) { - if (/\b(?:dose|mg|daily|tds|bd)\b/i.test(body)) return "Dose"; - if (/\b(?:monitor|baseline|fbc|anc|ecg|level)\b/i.test(body)) return "Monitoring"; - if (/\b(?:withhold|stop|cease|threshold|red|amber)\b/i.test(body)) return "Thresholds"; - if (/\b(?:gap|not enough|unsupported|insufficient)\b/i.test(body)) return "Source gap"; - return "Key point"; - } - return normalized; -} - -function applyProviderLabels(answer: RagAnswer): RagAnswer { - const inferredSourceOnlyFallback = - answer.routingMode === "extractive" || /(?:^|;\s*)generation_fallback(?::|$)/i.test(answer.routingReason ?? ""); - const answerQualityTier: RagAnswer["answerQualityTier"] = - answer.answerQualityTier ?? - (answer.modelUsed ? "model_synthesis" : inferredSourceOnlyFallback ? "source_only" : undefined); - const fallbackReason = - answer.fallbackReason ?? - (answerQualityTier === "source_only" ? (fallbackReasonFromRouting(answer.routingReason) ?? "source_only") : null); - const degradedActive = answerQualityTier === "source_only"; - return { - ...answer, - providerMode: answer.providerMode ?? ragProviderMode(), - answerQualityTier, - fallbackReason, - degradedMode: answer.degradedMode ?? { - active: degradedActive, - reason: degradedActive ? fallbackReason : null, - }, - }; -} - -// Public wrapper: runs quality finalization, then stamps provider/quality labels so the UI can -// disclose source-only (lower-quality) answers and verify-against-sources guidance. -function finalizeRagAnswerQuality(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { - return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass)); -} - -function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { - // Deterministic, template-built answers (document-support lists, table/visual source - // references) are well-formed by construction and carry no free-text clinical claims. - // The clinical-prose sanitizer/quality gate below is designed for model prose and would - // strip their document names (facility codes like "(NOCC)(AKG)" read as non-prose), - // turning a valid answer into garble that then fails the gate. Return them untouched. - if (answer.preformatted && answer.grounded) { - return answer; - } - const cleanedAnswer = sanitizeAnswerText(answer.answer); - const gapLikeAnswer = - /could not find enough clean|no relevant clinical source|no current source|cannot provide a clinical answer|cannot provide a source-backed clinical answer|nearby indexed passages|not strong enough to support a reliable answer|no specific\b.*\bcan be confirmed|do not contain indexed guidance|do not contain (?:specific\s+)?information|do not provide specific|no\b.*\bguidance\b.*\bincluded|defer to other sources/i.test( - cleanedAnswer, - ); - const existingGapAnswer = - gapLikeAnswer && (!answer.grounded || answer.routingMode === "strong" || answer.confidence === "low"); - if (existingGapAnswer) { - const gapAnswer = finalQualityGapAnswer(query, queryClass); - return { - ...answer, - answer: gapAnswer, - grounded: false, - confidence: "unsupported", - answerSections: [], - responseMode: "evidence_gap", - }; - } - - if (!answer.grounded && answer.confidence === "unsupported") { - return finalQualityFailure(answer, query, queryClass, "ungrounded_unsupported_answer"); - } - - let qualityFailureReason = !cleanedAnswer - ? "empty_after_sanitize" - : cleanedAnswer.length < 18 - ? "answer_too_short" - : generatedAnswerQualityFailureReason(answer, query, queryClass); - - if (qualityFailureReason) { - if (shouldPreserveSourceBackedGeneratedAnswer(answer, qualityFailureReason)) { - answer = { - ...answer, - confidence: answer.confidence === "low" ? "medium" : answer.confidence, - routingReason: [answer.routingReason, `final_quality_gate_source_backed_recovery:${qualityFailureReason}`] - .filter(Boolean) - .join("; "), - }; - qualityFailureReason = null; - } else { - return finalQualityFailure(answer, query, queryClass, qualityFailureReason); - } - } - - const answerKey = normalizeSectionText(cleanedAnswer).toLowerCase(); - const answerSections = (answer.answerSections ?? []) - .map((section) => { - const body = sanitizeAnswerText(section.body); - if (!body || hasClinicalAnswerQualityIssue(body) || isLowYieldClinicalText(body)) return null; - const bodyKey = normalizeSectionText(body).toLowerCase(); - const isDocumentListSection = section.kind === "documentation" || /\bdocument matches\b/i.test(section.heading); - if ( - !isDocumentListSection && - (bodyKey === answerKey || answerKey.includes(bodyKey) || bodyKey.includes(answerKey)) - ) { - return null; - } - const heading = cleanAnswerSectionHeading(section.heading, body); - return { - ...section, - heading, - body: boldHighYieldClinicalText(body, query), - kind: section.kind ?? sectionHeadingKind(heading), - supportLevel: section.supportLevel ?? "direct", - } satisfies AnswerSection; - }) - .filter((section): section is Exclude => Boolean(section)); - - return applyNumericVerification({ - ...answer, - answer: boldHighYieldClinicalText(cleanedAnswer, query), - answerSections, - }); -} - export async function searchChunksWithTelemetry(args: SearchChunksArgs) { assertGlobalSearchAllowed(args); throwIfAborted(args.signal); From f7c15bbbb64b2c91bfa6b69aa6371a37a5bcf25c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:00:36 +0800 Subject: [PATCH 2/6] docs: record rag.ts decomposition part-2 move 1 (L+M extraction) Co-Authored-By: Claude Opus 4.8 --- docs/process-hardening.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index c977d4d010..6a2e6d73d2 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -205,6 +205,12 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **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. +## rag.ts decomposition — part 2 (2026-07-07) + +- **Base:** branched `claude/rag-decomp-part2` off `origin/main` (8878151aa, includes part-1 #341 + retrieval-correctness #343 → golden set is now **36**, not the 23 the part-1 note recorded). Approved 3-move sequence: (1) L+M together, (2) retrieval variants H, (3) context-packing J → cache F. Method identical to part 1 (verbatim byte-identical block moves, one module per commit, per-move gate). The remeasured region map (do NOT trust part-1's line numbers post-drift): L = extractive-answer at 3954–5039 (1,085 lines) and M = answer-quality/reasoning-effort at 5040–5503 (464 lines), confirming part-1's family sizes. +- **Move 1 (shipped): L+M → `rag-extractive-answer.ts`.** Lines 3954–5503 moved verbatim (empty block diff modulo the `export` keyword added to 9 functions that now cross the boundary). Extracting L+M **together** keeps their mutual entanglement internal → no cross-module cycle between them. rag.ts imports the 12 names it still calls, re-exports the 6 tests import. Per the sanctioned "accept back-edges" path, the module imports **9 call-time helpers back from rag.ts** (`evidenceTextForGate`, `rankMemoryCardsForAnswer`, `deriveConfidence`, `scoreValue`, `machineReadableFallbackAnswer`, `buildAnswerScoreExplanations`, `buildIndexingQuality`, `collectMemoryCards`, `fallbackReasonFromRouting`) — a rag.ts↔module cycle that is runtime-safe (all uses are call-time; no `import/no-cycle` lint rule). 6 of those 9 gained an `export` keyword; the aliased `citationFromResult as resultCitation` import travels with the module; 5 imports orphaned by the move were removed from rag.ts. rag.ts 7,550 → 6,025 lines. Gate: tsc clean (56 pre-existing `fast-check` property-test errors only — `fast-check` is not installed in the shared node_modules; no new errors from the move), focused vitest 75/75, lint (only the 7 pre-existing rag.ts unused-import warnings; module 0), prettier clean. +- **Trap logged:** a backgrounded `tsc … | tail; echo EXIT` reports the pipeline's (echo's) exit, not tsc's — always read the captured EXIT marker, never trust the background "exit 0" notification. This masked both the true baseline (56 fast-check errors) and move 1's first-cut breakage (9 unexported functions + a missed aliased import) until vitest failed. + ## 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. From 6c00ebfb56c7f3092ff6f31febe19aa45d954732 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:19:33 +0800 Subject: [PATCH 3/6] refactor(rag): extract retrieval query-variant + alias + owner-scope family to rag-retrieval-variants.ts (move-only) Move 2 of rag.ts decomposition part 2 (retrieval-side). Lines 1999-2283 moved verbatim (byte-identical modulo the `export` keyword added to 5 declarations that now cross the module boundary; verified empty block diff otherwise): retrieval query-variant builder, alias normalization/expansion, live rag_aliases fetching, owner-scope guards (assertGlobalSearchAllowed, ownerScopeForDocumentFilteredRetrieval), weak-text-match relaxation, and the text-candidate budget. rag.ts imports the 11 names it still calls and re-exports the 6 that retrieval-query-variants.test.ts imports. Back-edges to rag.ts: SearchChunksArgs (type-only, no runtime cycle) and shouldShortCircuitUnsupportedSearch (one call-time value fn, gained an `export`; belongs to the soft-tail family that stays). 4 imports orphaned by the move (retrievalOwnerFilter, queriedZoneColour, isDemoMode, isLocalNoAuthMode) removed from rag.ts. rag.ts 6,025 -> 5,730 lines. Gate: tsc clean (no new errors vs baseline), focused vitest 102/102 (incl. retrieval-query-variants.test.ts exercising the moved builders), lint (no new warnings), prettier clean. Golden retrieval eval is a pre-merge gate; see PR notes on the current environment (OpenAI quota exhausted + live text-RPC returning 0 candidates) blocking a clean 36/36 run. Co-Authored-By: Claude Opus 4.8 --- src/lib/rag-retrieval-variants.ts | 292 +++++++++++++++++++++++++++ src/lib/rag.ts | 314 +++--------------------------- 2 files changed, 316 insertions(+), 290 deletions(-) create mode 100644 src/lib/rag-retrieval-variants.ts diff --git a/src/lib/rag-retrieval-variants.ts b/src/lib/rag-retrieval-variants.ts new file mode 100644 index 0000000000..4396d04550 --- /dev/null +++ b/src/lib/rag-retrieval-variants.ts @@ -0,0 +1,292 @@ +import { createAdminClient } from "@/lib/supabase/admin"; +import { retrievalOwnerFilter } from "@/lib/owner-scope"; +import { buildClinicalTextSearchQuery, normalizedClinicalSearchTokens, queriedZoneColour } from "@/lib/clinical-search"; +import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { shouldShortCircuitUnsupportedSearch, type SearchChunksArgs } from "@/lib/rag"; +import type { ClinicalQueryAnalysis, RagQueryClass, SearchResult } from "@/lib/types"; + +const maxRetrievalQueryVariants = 4; +export const maxTextRpcQueryVariants = 3; +const ragAliasCacheTtlMs = 60_000; +const maxRagAliasesPerScope = 200; +const maxRagAliasExpansions = 12; + +export function textCandidateBudgetForQueryClass(queryClass: RagQueryClass | undefined, topK: number) { + if (queryClass === "comparison") return Math.max(topK * 7, 72); + if (queryClass === "table_threshold" || queryClass === "medication_dose_risk") return Math.max(topK * 4, 40); + if (queryClass === "document_lookup") return Math.max(topK * 3, 24); + if (queryClass === "unsupported_or_general") return Math.max(topK * 2, 16); + return Math.max(topK * 4, 32); +} + +export type RagAliasInput = { + alias: string; + canonical: string; + alias_type?: string | null; + weight?: number | null; + owner_id?: string | null; +}; + +const ragAliasCache = new Map(); + +export function normalizeRetrievalVariant(value: string) { + return value.normalize("NFKC").replace(/\s+/g, " ").trim(); +} + +function retrievalVariantFromTerms(terms: string[]) { + return buildClinicalTextSearchQuery(terms.filter(Boolean).join(" ")); +} + +function normalizeAliasLookup(value: string) { + return value + .normalize("NFKC") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function escapedAliasPattern(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"); +} + +function aliasAppearsInQuery(normalizedQuery: string, alias: string) { + const normalizedAlias = normalizeAliasLookup(alias); + if (!normalizedQuery || !normalizedAlias) return false; + const pattern = new RegExp(`(?:^|\\s)${escapedAliasPattern(normalizedAlias)}(?:\\s|$)`, "i"); + return pattern.test(normalizedQuery); +} + +export function selectRagAliasExpansions(query: string, aliases: RagAliasInput[], limit = maxRagAliasExpansions) { + const normalizedQuery = normalizeAliasLookup(query); + const expansions: string[] = []; + const seen = new Set(); + + const sorted = [...aliases].sort((left, right) => { + const leftWeight = typeof left.weight === "number" ? left.weight : 1; + const rightWeight = typeof right.weight === "number" ? right.weight : 1; + return rightWeight - leftWeight; + }); + + for (const alias of sorted) { + if (expansions.length >= limit) break; + if (!aliasAppearsInQuery(normalizedQuery, alias.alias)) continue; + const canonical = normalizeRetrievalVariant(alias.canonical); + const key = canonical.toLowerCase(); + if (!canonical || seen.has(key)) continue; + seen.add(key); + expansions.push(canonical); + } + + return expansions; +} + +export function shouldApplyUnsupportedSearchShortCircuit( + query: string, + analysis: ClinicalQueryAnalysis, + aliasExpansions: string[] = [], +) { + return aliasExpansions.length === 0 && shouldShortCircuitUnsupportedSearch(query, analysis); +} + +export async function fetchEnabledRagAliases( + supabase: ReturnType, + ownerId?: string, +): Promise { + const cacheKey = ownerId ? `owner:${ownerId}` : "global"; + const cached = ragAliasCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.aliases; + + async function readScope(scopeOwnerId: string | null) { + let query = supabase + .from("rag_aliases") + .select("alias,canonical,alias_type,weight,owner_id") + .eq("enabled", true) + .order("weight", { ascending: false }) + .limit(maxRagAliasesPerScope); + query = scopeOwnerId ? query.eq("owner_id", scopeOwnerId) : query.is("owner_id", null); + const { data, error } = await query; + if (error) throw error; + return (data ?? []) as RagAliasInput[]; + } + + try { + const [globalAliases, ownerAliases] = await Promise.all([ + readScope(null), + ownerId ? readScope(ownerId) : Promise.resolve([] as RagAliasInput[]), + ]); + const merged: RagAliasInput[] = []; + const seen = new Set(); + for (const alias of [...ownerAliases, ...globalAliases]) { + const key = `${normalizeAliasLookup(alias.alias)}||${normalizeAliasLookup(alias.canonical)}`; + if (!alias.alias?.trim() || !alias.canonical?.trim() || seen.has(key)) continue; + seen.add(key); + merged.push(alias); + if (merged.length >= maxRagAliasesPerScope) break; + } + ragAliasCache.set(cacheKey, { aliases: merged, expiresAt: Date.now() + ragAliasCacheTtlMs }); + return merged; + } catch { + // Do not cache an empty result on a transient rag_aliases read failure: caching [] would suppress + // alias-based query expansion (and could let an alias-rescuable query short-circuit) for the whole + // TTL. Return empty for this call only and retry on the next call. + return []; + } +} + +export function assertGlobalSearchAllowed(args: SearchChunksArgs) { + if (args.ownerId || args.allowGlobalSearch || isDemoMode() || isLocalNoAuthMode()) return; + if (process.env.NODE_ENV === "production") { + throw new Error("Global RAG search requires allowGlobalSearch=true or an explicit ownerId."); + } +} + +export function ownerScopeForDocumentFilteredRetrieval( + ownerId: string | undefined, + documentIds: string[] | undefined, + allowGlobalSearch?: boolean, +) { + return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); +} + +export function buildRetrievalQueryVariants( + query: string, + analysis: ClinicalQueryAnalysis, + aliases: RagAliasInput[] = [], +) { + const variants: string[] = []; + const seen = new Set(); + const aliasExpansions = selectRagAliasExpansions(query, aliases); + const addVariant = (value: string) => { + const normalized = normalizeRetrievalVariant(value); + if (!normalized) return; + const key = normalized.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + variants.push(normalized); + }; + + addVariant(buildClinicalTextSearchQuery(query)); + aliasExpansions.slice(0, 2).forEach(addVariant); + if (/\bpatient property\b/i.test(query)) { + addVariant("patient property"); + } + if (/\bclozapine\b/i.test(query) && /\b(?:anc|fbc|wbc|neutrophil|white cell)\b/i.test(query)) { + addVariant("clozapine anc fbc"); + addVariant("clozapine monitoring"); + } + if (analysis.queryClass === "comparison" && /\badmission\b/i.test(query) && /\bdischarge\b/i.test(query)) { + addVariant("admission community patients"); + addVariant("discharge community patients"); + addVariant("admission discharge"); + } + if ( + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && + /\b(?:risk|red\s*zone|red|urgent|escalat|next step)\b/i.test(query) + ) { + addVariant("risk flow"); + // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" + // and "risk flow review urgent escalation" variants required all terms in one + // chunk and did not reliably contribute candidates to the pool. A " zone" variant retrieves the small, + // precise set of zone-action chunks (escalation protocols, observation and + // response charts, risk-matrix cells) that answer zone / next-step questions. + // Match the zone the query actually names so an amber-zone question does not + // pull red-zone chunks into its candidate pool. + const zoneColour = queriedZoneColour(query); + if (zoneColour) { + addVariant(`${zoneColour} zone`); + } + } + addVariant(analysis.queryRewrite.searchQuery); + + addVariant( + retrievalVariantFromTerms([ + ...analysis.canonicalTerms, + ...analysis.acronyms, + ...analysis.typoCorrections.map((correction) => correction.to), + ...analysis.expandedTerms.slice(0, 8), + ...analysis.queryRewrite.expansions.slice(0, 10), + ...aliasExpansions.slice(0, 6), + ]), + ); + + if (analysis.documentTitleIntent) { + addVariant( + retrievalVariantFromTerms([ + ...analysis.documentTitleTerms, + ...analysis.canonicalTerms.slice(0, 6), + ...analysis.acronyms, + ...aliasExpansions.slice(0, 4), + ]), + ); + } + + if (analysis.medications.length > 0 || analysis.thresholdTerms.length > 0) { + addVariant( + retrievalVariantFromTerms([ + ...analysis.medications, + ...analysis.thresholdTerms, + ...analysis.acronyms, + ...analysis.canonicalTerms.slice(0, 8), + ...aliasExpansions.slice(0, 6), + ]), + ); + } + + const normalizedTokens = normalizedClinicalSearchTokens(query); + if (normalizedTokens.length > 10) { + const coreTerms = [ + ...analysis.medications, + ...analysis.thresholdTerms, + ...analysis.documentTitleTerms, + ...analysis.canonicalTerms, + ...aliasExpansions.slice(0, 4), + ...normalizedTokens, + ]; + addVariant(retrievalVariantFromTerms(coreTerms.slice(0, 10))); + } + + return variants.slice(0, maxRetrievalQueryVariants); +} + +// P8b: websearch_to_tsquery ANDs every term, so a long multi-term query (e.g. "ciwa score threshold +// drug treatment alcohol withdrawal") can match zero chunks even when the answer clearly exists — +// no single chunk contains all seven terms. Relax the primary variant to a term-OR query so recall +// is recovered; ts_rank_cd still ranks chunks matching more terms highest, so topical docs surface +// on top rather than flooding with single-term matches. Only used as a fallback when the strict +// AND variants returned nothing, so it never displaces a working precise match. +export function relaxVariantToOrQuery(variant: string): string | null { + const tokens = Array.from( + new Set( + variant + .toLowerCase() + .replace(/[^a-z0-9\s]/g, " ") + .split(/\s+/) + .filter((token) => token.length > 1 && token !== "or"), + ), + ); + if (tokens.length < 2) return null; + return tokens.join(" OR "); +} + +// Mirrors the minimum meaningful text-signal floor used by answer routing +// (see textSignalFloor usage in rag-routing.ts): a strict-AND result set whose best +// text_rank sits below it carries almost no lexical evidence. +const weakTextMatchTopRankFloor = 0.05; +const weakTextMatchMinResultCount = 3; +// Above this rank the best strict match is a precise lexical hit; augmenting a sparse set +// around it only adds OR noise and a needless RPC round-trip (single-strong-match queries +// like exact table lookups must stay one-RPC retrievals). +const strongTextMatchTopRankBar = 0.3; + +// Strict-AND matched something, but so weakly (sparse set of middling matches, or a +// negligible best text rank) that the right chunk may be buried outside the candidate pool. +// In that case OR-relaxed recall is appended BEHIND the strict matches (append-only: strict +// results keep merge precedence), so this can widen the pool but never displace a precise +// match. A sparse set anchored by a strong hit does NOT relax. +export function shouldRelaxWeakTextMatches(merged: SearchResult[]): boolean { + if (merged.length === 0) return false; + const topTextRank = merged.reduce((top, result) => Math.max(top, result.text_rank ?? 0), 0); + if (topTextRank >= strongTextMatchTopRankBar) return false; + if (merged.length < weakTextMatchMinResultCount) return true; + return topTextRank < weakTextMatchTopRankFloor; +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 09ea8c66d2..140dba75fa 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1,5 +1,5 @@ import { createAdminClient } from "@/lib/supabase/admin"; -import { requireOwnerScope, retrievalOwnerFilter } from "@/lib/owner-scope"; +import { requireOwnerScope } from "@/lib/owner-scope"; import { classifyCorpusGrounding } from "@/lib/corpus-grounding"; import type { Database, Json } from "@/lib/supabase/database.types"; import { embedTextWithTelemetry, generateStructuredTextResult, type OpenAITextResult } from "@/lib/openai"; @@ -42,6 +42,27 @@ export { sourceBackedGenerationTimeoutAnswer, strongReasoningEffortForQueryClass, } from "@/lib/rag-extractive-answer"; +import { + assertGlobalSearchAllowed, + buildRetrievalQueryVariants, + fetchEnabledRagAliases, + maxTextRpcQueryVariants, + normalizeRetrievalVariant, + ownerScopeForDocumentFilteredRetrieval, + relaxVariantToOrQuery, + selectRagAliasExpansions, + shouldApplyUnsupportedSearchShortCircuit, + shouldRelaxWeakTextMatches, + textCandidateBudgetForQueryClass, +} from "@/lib/rag-retrieval-variants"; +export { + buildRetrievalQueryVariants, + relaxVariantToOrQuery, + selectRagAliasExpansions, + shouldApplyUnsupportedSearchShortCircuit, + shouldRelaxWeakTextMatches, + textCandidateBudgetForQueryClass, +} from "@/lib/rag-retrieval-variants"; import { buildRagSourceBlock, compactContextText } from "@/lib/rag-source-block"; export { buildRagSourceBlock, truncateForModel } from "@/lib/rag-source-block"; import { extractNumericTokens, VERIFY_AGAINST_SOURCE_NOTE, verifyAnswerNumbers } from "@/lib/answer-verification"; @@ -54,12 +75,11 @@ import { hasStructuredThresholdEvidence, normalizedClinicalSearchTokens, rankClinicalResults, - queriedZoneColour, riskZoneActionPattern, riskZoneContextPattern, zoneContextPatternsForQuery, } from "@/lib/clinical-search"; -import { env, isDemoMode, isLocalNoAuthMode, requestedOpenAIAnswerModels } from "@/lib/env"; +import { env, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; import { queryCacheKeyForStorage, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; import { ragCacheKeyMatchesOwner } from "@/lib/rag-cache-utils"; @@ -1326,7 +1346,7 @@ function unsupportedSoftTailEligible(analysis: ClinicalQueryAnalysis) { return true; } -function shouldShortCircuitUnsupportedSearch(query: string, analysis: ClinicalQueryAnalysis) { +export function shouldShortCircuitUnsupportedSearch(query: string, analysis: ClinicalQueryAnalysis) { if (unavailableDocumentNoisePattern.test(query)) return true; if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return true; if (!unsupportedSoftTailEligible(analysis)) return false; @@ -1996,292 +2016,6 @@ function mergeSearchResults(primary: SearchResult[], secondary: SearchResult[]) return Array.from(merged.values()); } -const maxRetrievalQueryVariants = 4; -const maxTextRpcQueryVariants = 3; -const ragAliasCacheTtlMs = 60_000; -const maxRagAliasesPerScope = 200; -const maxRagAliasExpansions = 12; - -export function textCandidateBudgetForQueryClass(queryClass: RagQueryClass | undefined, topK: number) { - if (queryClass === "comparison") return Math.max(topK * 7, 72); - if (queryClass === "table_threshold" || queryClass === "medication_dose_risk") return Math.max(topK * 4, 40); - if (queryClass === "document_lookup") return Math.max(topK * 3, 24); - if (queryClass === "unsupported_or_general") return Math.max(topK * 2, 16); - return Math.max(topK * 4, 32); -} - -export type RagAliasInput = { - alias: string; - canonical: string; - alias_type?: string | null; - weight?: number | null; - owner_id?: string | null; -}; - -const ragAliasCache = new Map(); - -function normalizeRetrievalVariant(value: string) { - return value.normalize("NFKC").replace(/\s+/g, " ").trim(); -} - -function retrievalVariantFromTerms(terms: string[]) { - return buildClinicalTextSearchQuery(terms.filter(Boolean).join(" ")); -} - -function normalizeAliasLookup(value: string) { - return value - .normalize("NFKC") - .toLowerCase() - .replace(/[^a-z0-9]+/g, " ") - .trim(); -} - -function escapedAliasPattern(value: string) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"); -} - -function aliasAppearsInQuery(normalizedQuery: string, alias: string) { - const normalizedAlias = normalizeAliasLookup(alias); - if (!normalizedQuery || !normalizedAlias) return false; - const pattern = new RegExp(`(?:^|\\s)${escapedAliasPattern(normalizedAlias)}(?:\\s|$)`, "i"); - return pattern.test(normalizedQuery); -} - -export function selectRagAliasExpansions(query: string, aliases: RagAliasInput[], limit = maxRagAliasExpansions) { - const normalizedQuery = normalizeAliasLookup(query); - const expansions: string[] = []; - const seen = new Set(); - - const sorted = [...aliases].sort((left, right) => { - const leftWeight = typeof left.weight === "number" ? left.weight : 1; - const rightWeight = typeof right.weight === "number" ? right.weight : 1; - return rightWeight - leftWeight; - }); - - for (const alias of sorted) { - if (expansions.length >= limit) break; - if (!aliasAppearsInQuery(normalizedQuery, alias.alias)) continue; - const canonical = normalizeRetrievalVariant(alias.canonical); - const key = canonical.toLowerCase(); - if (!canonical || seen.has(key)) continue; - seen.add(key); - expansions.push(canonical); - } - - return expansions; -} - -export function shouldApplyUnsupportedSearchShortCircuit( - query: string, - analysis: ClinicalQueryAnalysis, - aliasExpansions: string[] = [], -) { - return aliasExpansions.length === 0 && shouldShortCircuitUnsupportedSearch(query, analysis); -} - -async function fetchEnabledRagAliases( - supabase: ReturnType, - ownerId?: string, -): Promise { - const cacheKey = ownerId ? `owner:${ownerId}` : "global"; - const cached = ragAliasCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.aliases; - - async function readScope(scopeOwnerId: string | null) { - let query = supabase - .from("rag_aliases") - .select("alias,canonical,alias_type,weight,owner_id") - .eq("enabled", true) - .order("weight", { ascending: false }) - .limit(maxRagAliasesPerScope); - query = scopeOwnerId ? query.eq("owner_id", scopeOwnerId) : query.is("owner_id", null); - const { data, error } = await query; - if (error) throw error; - return (data ?? []) as RagAliasInput[]; - } - - try { - const [globalAliases, ownerAliases] = await Promise.all([ - readScope(null), - ownerId ? readScope(ownerId) : Promise.resolve([] as RagAliasInput[]), - ]); - const merged: RagAliasInput[] = []; - const seen = new Set(); - for (const alias of [...ownerAliases, ...globalAliases]) { - const key = `${normalizeAliasLookup(alias.alias)}||${normalizeAliasLookup(alias.canonical)}`; - if (!alias.alias?.trim() || !alias.canonical?.trim() || seen.has(key)) continue; - seen.add(key); - merged.push(alias); - if (merged.length >= maxRagAliasesPerScope) break; - } - ragAliasCache.set(cacheKey, { aliases: merged, expiresAt: Date.now() + ragAliasCacheTtlMs }); - return merged; - } catch { - // Do not cache an empty result on a transient rag_aliases read failure: caching [] would suppress - // alias-based query expansion (and could let an alias-rescuable query short-circuit) for the whole - // TTL. Return empty for this call only and retry on the next call. - return []; - } -} - -function assertGlobalSearchAllowed(args: SearchChunksArgs) { - if (args.ownerId || args.allowGlobalSearch || isDemoMode() || isLocalNoAuthMode()) return; - if (process.env.NODE_ENV === "production") { - throw new Error("Global RAG search requires allowGlobalSearch=true or an explicit ownerId."); - } -} - -function ownerScopeForDocumentFilteredRetrieval( - ownerId: string | undefined, - documentIds: string[] | undefined, - allowGlobalSearch?: boolean, -) { - return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); -} - -export function buildRetrievalQueryVariants( - query: string, - analysis: ClinicalQueryAnalysis, - aliases: RagAliasInput[] = [], -) { - const variants: string[] = []; - const seen = new Set(); - const aliasExpansions = selectRagAliasExpansions(query, aliases); - const addVariant = (value: string) => { - const normalized = normalizeRetrievalVariant(value); - if (!normalized) return; - const key = normalized.toLowerCase(); - if (seen.has(key)) return; - seen.add(key); - variants.push(normalized); - }; - - addVariant(buildClinicalTextSearchQuery(query)); - aliasExpansions.slice(0, 2).forEach(addVariant); - if (/\bpatient property\b/i.test(query)) { - addVariant("patient property"); - } - if (/\bclozapine\b/i.test(query) && /\b(?:anc|fbc|wbc|neutrophil|white cell)\b/i.test(query)) { - addVariant("clozapine anc fbc"); - addVariant("clozapine monitoring"); - } - if (analysis.queryClass === "comparison" && /\badmission\b/i.test(query) && /\bdischarge\b/i.test(query)) { - addVariant("admission community patients"); - addVariant("discharge community patients"); - addVariant("admission discharge"); - } - if ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && - /\b(?:risk|red\s*zone|red|urgent|escalat|next step)\b/i.test(query) - ) { - addVariant("risk flow"); - // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" - // and "risk flow review urgent escalation" variants required all terms in one - // chunk and did not reliably contribute candidates to the pool. A " zone" variant retrieves the small, - // precise set of zone-action chunks (escalation protocols, observation and - // response charts, risk-matrix cells) that answer zone / next-step questions. - // Match the zone the query actually names so an amber-zone question does not - // pull red-zone chunks into its candidate pool. - const zoneColour = queriedZoneColour(query); - if (zoneColour) { - addVariant(`${zoneColour} zone`); - } - } - addVariant(analysis.queryRewrite.searchQuery); - - addVariant( - retrievalVariantFromTerms([ - ...analysis.canonicalTerms, - ...analysis.acronyms, - ...analysis.typoCorrections.map((correction) => correction.to), - ...analysis.expandedTerms.slice(0, 8), - ...analysis.queryRewrite.expansions.slice(0, 10), - ...aliasExpansions.slice(0, 6), - ]), - ); - - if (analysis.documentTitleIntent) { - addVariant( - retrievalVariantFromTerms([ - ...analysis.documentTitleTerms, - ...analysis.canonicalTerms.slice(0, 6), - ...analysis.acronyms, - ...aliasExpansions.slice(0, 4), - ]), - ); - } - - if (analysis.medications.length > 0 || analysis.thresholdTerms.length > 0) { - addVariant( - retrievalVariantFromTerms([ - ...analysis.medications, - ...analysis.thresholdTerms, - ...analysis.acronyms, - ...analysis.canonicalTerms.slice(0, 8), - ...aliasExpansions.slice(0, 6), - ]), - ); - } - - const normalizedTokens = normalizedClinicalSearchTokens(query); - if (normalizedTokens.length > 10) { - const coreTerms = [ - ...analysis.medications, - ...analysis.thresholdTerms, - ...analysis.documentTitleTerms, - ...analysis.canonicalTerms, - ...aliasExpansions.slice(0, 4), - ...normalizedTokens, - ]; - addVariant(retrievalVariantFromTerms(coreTerms.slice(0, 10))); - } - - return variants.slice(0, maxRetrievalQueryVariants); -} - -// P8b: websearch_to_tsquery ANDs every term, so a long multi-term query (e.g. "ciwa score threshold -// drug treatment alcohol withdrawal") can match zero chunks even when the answer clearly exists — -// no single chunk contains all seven terms. Relax the primary variant to a term-OR query so recall -// is recovered; ts_rank_cd still ranks chunks matching more terms highest, so topical docs surface -// on top rather than flooding with single-term matches. Only used as a fallback when the strict -// AND variants returned nothing, so it never displaces a working precise match. -export function relaxVariantToOrQuery(variant: string): string | null { - const tokens = Array.from( - new Set( - variant - .toLowerCase() - .replace(/[^a-z0-9\s]/g, " ") - .split(/\s+/) - .filter((token) => token.length > 1 && token !== "or"), - ), - ); - if (tokens.length < 2) return null; - return tokens.join(" OR "); -} - -// Mirrors the minimum meaningful text-signal floor used by answer routing -// (see textSignalFloor usage in rag-routing.ts): a strict-AND result set whose best -// text_rank sits below it carries almost no lexical evidence. -const weakTextMatchTopRankFloor = 0.05; -const weakTextMatchMinResultCount = 3; -// Above this rank the best strict match is a precise lexical hit; augmenting a sparse set -// around it only adds OR noise and a needless RPC round-trip (single-strong-match queries -// like exact table lookups must stay one-RPC retrievals). -const strongTextMatchTopRankBar = 0.3; - -// Strict-AND matched something, but so weakly (sparse set of middling matches, or a -// negligible best text rank) that the right chunk may be buried outside the candidate pool. -// In that case OR-relaxed recall is appended BEHIND the strict matches (append-only: strict -// results keep merge precedence), so this can widen the pool but never displace a precise -// match. A sparse set anchored by a strong hit does NOT relax. -export function shouldRelaxWeakTextMatches(merged: SearchResult[]): boolean { - if (merged.length === 0) return false; - const topTextRank = merged.reduce((top, result) => Math.max(top, result.text_rank ?? 0), 0); - if (topTextRank >= strongTextMatchTopRankBar) return false; - if (merged.length < weakTextMatchMinResultCount) return true; - return topTextRank < weakTextMatchTopRankFloor; -} - async function searchTextChunkCandidates(args: { supabase: ReturnType; queryVariants: string[]; From 1bb9fb4efc7864d57e71daf4fccb7490ef15ad1c Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:22:45 +0800 Subject: [PATCH 4/6] docs: record part-2 move 2 (retrieval variants) + environment-blocked golden eval Co-Authored-By: Claude Opus 4.8 --- docs/process-hardening.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 6a2e6d73d2..5e1f930f36 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -210,6 +210,8 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **Base:** branched `claude/rag-decomp-part2` off `origin/main` (8878151aa, includes part-1 #341 + retrieval-correctness #343 → golden set is now **36**, not the 23 the part-1 note recorded). Approved 3-move sequence: (1) L+M together, (2) retrieval variants H, (3) context-packing J → cache F. Method identical to part 1 (verbatim byte-identical block moves, one module per commit, per-move gate). The remeasured region map (do NOT trust part-1's line numbers post-drift): L = extractive-answer at 3954–5039 (1,085 lines) and M = answer-quality/reasoning-effort at 5040–5503 (464 lines), confirming part-1's family sizes. - **Move 1 (shipped): L+M → `rag-extractive-answer.ts`.** Lines 3954–5503 moved verbatim (empty block diff modulo the `export` keyword added to 9 functions that now cross the boundary). Extracting L+M **together** keeps their mutual entanglement internal → no cross-module cycle between them. rag.ts imports the 12 names it still calls, re-exports the 6 tests import. Per the sanctioned "accept back-edges" path, the module imports **9 call-time helpers back from rag.ts** (`evidenceTextForGate`, `rankMemoryCardsForAnswer`, `deriveConfidence`, `scoreValue`, `machineReadableFallbackAnswer`, `buildAnswerScoreExplanations`, `buildIndexingQuality`, `collectMemoryCards`, `fallbackReasonFromRouting`) — a rag.ts↔module cycle that is runtime-safe (all uses are call-time; no `import/no-cycle` lint rule). 6 of those 9 gained an `export` keyword; the aliased `citationFromResult as resultCitation` import travels with the module; 5 imports orphaned by the move were removed from rag.ts. rag.ts 7,550 → 6,025 lines. Gate: tsc clean (56 pre-existing `fast-check` property-test errors only — `fast-check` is not installed in the shared node_modules; no new errors from the move), focused vitest 75/75, lint (only the 7 pre-existing rag.ts unused-import warnings; module 0), prettier clean. - **Trap logged:** a backgrounded `tsc … | tail; echo EXIT` reports the pipeline's (echo's) exit, not tsc's — always read the captured EXIT marker, never trust the background "exit 0" notification. This masked both the true baseline (56 fast-check errors) and move 1's first-cut breakage (9 unexported functions + a missed aliased import) until vitest failed. +- **Move 2 (shipped): retrieval variants H → `rag-retrieval-variants.ts`.** Lines 1999–2283 moved verbatim (empty block diff modulo the `export` keyword added to 5 declarations): `buildRetrievalQueryVariants`, alias normalize/expand + live `fetchEnabledRagAliases`, `assertGlobalSearchAllowed`/`ownerScopeForDocumentFilteredRetrieval`, `shouldRelaxWeakTextMatches`, `textCandidateBudgetForQueryClass`, budget consts. rag.ts imports the 11 names it calls, re-exports the 6 `retrieval-query-variants.test.ts` imports. Back-edges: `SearchChunksArgs` (type-only, no runtime cycle) + `shouldShortCircuitUnsupportedSearch` (one call-time value fn, gained an `export`; belongs to the soft-tail family staying in rag.ts). 4 orphaned imports removed from rag.ts. rag.ts 6,025 → 5,750 lines. Gate: tsc clean, focused vitest 102/102 (incl. `retrieval-query-variants.test.ts`), lint (7 pre-existing warnings only), prettier clean. +- **Golden retrieval eval environment-blocked (both HEAD and pristine `origin/main`):** `npm run eval:retrieval:quality` currently FAILS ~36/36 with `text_candidates:0` on every case and `OpenAI quota is exhausted`. This is **not** a code regression: swapping in pristine `origin/main` `rag.ts` (moved modules inert) reproduces the identical failure pattern (15/15 lexical cases `text_candidates:0`, byte-for-byte). So the live Supabase text-RPC is returning 0 candidates and the OpenAI key's quota is spent — an infrastructure state, pre-existing and independent of this branch. Behavior preservation for moves 1–2 is therefore evidenced by byte-identical moves + tsc + focused vitest + **pristine-eval equivalence**; the mandated clean 36/36 must be run once the live keys/quota are restored (or in CI) before merge. ## Answer-thread Back button: URL and visible answer can disagree (2026-07-06) From a5a12352a0adbc8f982e8c6a25a0b1d5ffc87fad Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:48:40 +0800 Subject: [PATCH 5/6] refactor(rag): extract cache region + context-packing to rag-cache.ts (move-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move 3 (final) of rag.ts decomposition part 2. The cache functionality is interleaved in rag.ts with the query-classifier, unsupported-soft-tail, and query-expansion regions, so this is a multi-region verbatim move: four contiguous regions assembled into rag-cache.ts, each byte-identical to source (verified; diff shows only the `export` keyword added to 11 declarations that now cross the module boundary): - cache maps/consts (answerCache, answerInflight, searchCache, cacheIndexingVersionCache, …) - cache-key helpers (scopeKey, normalizedCacheQuery, cacheIndexingVersionCacheKey) - main cache block (modeKey…invalidateRagCachesForDocumentMutation: get/set answer+search, shared-cache, cacheIndexingVersion, stableHash, retrievalPlanCacheQuery, invalidation) - context-packing (sourceContextPackLimit, packedContextCacheKey, packAdjacentSourceContext), grouped here per the part-1 note because it needs stableHash + committed-generation. rag.ts imports the 12 names it still calls (incl. the answerInflight map) and re-exports the 4 consumed elsewhere (invalidateRagCachesForOwner/…ForDocumentMutation via API routes; packedContextCacheKey + retrievalPlanCacheQuery via tests). Back-edges to rag.ts: SearchChunksArgs + SearchTelemetry (type-only, no runtime cycle) and one call-time value fn retrievalPlanForQueryClass (gained an `export`). 3 imports orphaned by the move (queryCacheKeyForStorage, ragCacheKeyMatchesOwner, createHash) removed from rag.ts. rag.ts 5,750 -> 5,108 lines (7,550 at part-2 start; -2,442 / -32% across the 3 moves). Gate: tsc clean (no new errors vs baseline), focused vitest 118/118, lint (no new warnings; module 0), prettier clean. Golden retrieval eval is a pre-merge gate; see PR notes — currently blocked by an eval-owner/corpus data mismatch (docs are owner_id=null, RAG_EVAL_OWNER_ID owns 0), independent of this branch (pristine origin/main fails identically). Co-Authored-By: Claude Opus 4.8 --- src/lib/rag-cache.ts | 673 ++++++++++++++++++++++++++++++++++++++++++ src/lib/rag.ts | 686 ++----------------------------------------- 2 files changed, 695 insertions(+), 664 deletions(-) create mode 100644 src/lib/rag-cache.ts diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts new file mode 100644 index 0000000000..371ef3462e --- /dev/null +++ b/src/lib/rag-cache.ts @@ -0,0 +1,673 @@ +import { createHash } from "node:crypto"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { buildClinicalTextSearchQuery } from "@/lib/clinical-search"; +import { ragDeepMemoryVersion } from "@/lib/deep-memory"; +import { env } from "@/lib/env"; +import { queryCacheKeyForStorage } from "@/lib/query-privacy"; +import { ragCacheKeyMatchesOwner } from "@/lib/rag-cache-utils"; +import { compactContextText } from "@/lib/rag-source-block"; +import { committedIndexGeneration } from "@/lib/reindex-pipeline"; +import { normalizeSourceMetadata } from "@/lib/source-metadata"; +import { retrievalPlanForQueryClass, type SearchChunksArgs, type SearchTelemetry } from "@/lib/rag"; +import type { Json } from "@/lib/supabase/database.types"; +import type { RagAnswer, RagQueryClass, SearchResult } from "@/lib/types"; + +const answerCache = new Map(); +export const answerInflight = new Map>(); +const searchCache = new Map< + string, + { expiresAt: number; results: SearchResult[]; telemetry: SearchTelemetry; indexingVersion: string } +>(); +const ragCacheDependencyVersion = "rag-cache-v12"; +const cacheIndexingVersionTtlMs = 5000; +const cacheIndexingVersionCache = new Map(); + +function scopeKey(args: Pick) { + const scope = args.documentIds?.length + ? [...args.documentIds].sort().join(",") + : args.documentId + ? args.documentId + : "all-documents"; + return scope; +} + +function normalizedCacheQuery(query: string) { + return buildClinicalTextSearchQuery(query).toLowerCase().replace(/\s+/g, " ").trim(); +} + +function cacheIndexingVersionCacheKey(args: Pick) { + return [args.ownerId ?? "anonymous", scopeKey(args)].join("|"); +} + +function modeKey(args: Pick) { + return args.queryMode ?? "auto"; +} + +export function scopedAnswerCacheKey( + args: Pick, +) { + return [ + ragCacheDependencyVersion, + args.ownerId ?? "anonymous", + scopeKey(args), + modeKey(args), + args.query.trim().toLowerCase().replace(/\s+/g, " "), + ].join("|"); +} + +export function cloneAnswer(answer: RagAnswer) { + return structuredClone(answer); +} + +export async function getCachedAnswer( + args: Pick, + startedAt: number, +): Promise { + if (args.skipCache) return null; + if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return null; + + const key = scopedAnswerCacheKey(args); + const cached = answerCache.get(key); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + answerCache.delete(key); + return null; + } + const indexingVersion = await cacheIndexingVersion(args); + if (cached.indexingVersion !== indexingVersion) { + answerCache.delete(key); + return null; + } + + const answer = cloneAnswer(cached.answer); + answer.routingReason = answer.routingReason ? `${answer.routingReason}; answer_cache_hit` : "answer_cache_hit"; + answer.latencyTimings = { + ...answer.latencyTimings, + total_latency_ms: Date.now() - startedAt, + }; + return answer; +} + +export async function setCachedAnswer( + args: Pick, + answer: RagAnswer, + options?: { indexingVersionAtRetrievalStart?: string | null }, +): Promise { + if (args.skipCache) return; + if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return; + + if (options?.indexingVersionAtRetrievalStart) { + const currentIndexingVersion = await cacheIndexingVersion(args); + if (currentIndexingVersion !== options.indexingVersionAtRetrievalStart) return; + } + + const indexingVersion = await cacheIndexingVersion(args); + const key = scopedAnswerCacheKey(args); + answerCache.set(key, { + expiresAt: Date.now() + env.RAG_ANSWER_CACHE_TTL_MS, + answer: cloneAnswer(answer), + indexingVersion, + }); + + while (answerCache.size > env.RAG_ANSWER_CACHE_SIZE) { + const oldestKey = answerCache.keys().next().value; + if (!oldestKey) break; + answerCache.delete(oldestKey); + } + setSharedCachedAnswer(args, answer); +} + +function stableHash(value: string) { + return createHash("sha256").update(value).digest("hex").slice(0, 16); +} + +export function retrievalPlanCacheQuery( + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding" + >, + queryClass?: RagQueryClass, + queryVariants: string[] = [], +) { + const normalizedQuery = normalizedCacheQuery(args.query); + const variantHash = stableHash(queryVariants.join("\n")); + const cacheKey = [ + `plan:${retrievalPlanForQueryClass(queryClass)}`, + `class:${queryClass ?? "unknown"}`, + `query:${normalizedQuery}`, + `variants:${variantHash}`, + `mode:${modeKey(args)}`, + `topK:${args.topK ?? 8}`, + `min:${args.minSimilarity ?? 0.15}`, + `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, + `rag:${ragDeepMemoryVersion}`, + `force:${args.forceEmbedding ? 1 : 0}`, + ].join("|"); + return queryCacheKeyForStorage(cacheKey); +} + +function scopedSearchCacheKey(args: SearchChunksArgs, queryClass?: RagQueryClass, queryVariants: string[] = []) { + return [ + ragCacheDependencyVersion, + args.ownerId ?? "anonymous", + scopeKey(args), + retrievalPlanCacheQuery(args, queryClass, queryVariants), + ].join("|"); +} + +function cloneSearchResults(results: SearchResult[]) { + return structuredClone(results); +} + +function normalizeCacheStorageTelemetry(telemetry: SearchTelemetry): SearchTelemetry { + return { + ...telemetry, + shared_cache_hit: false, + shared_cache_status: undefined, + shared_cache_miss_reason: null, + }; +} + +export async function getCachedSearch( + args: SearchChunksArgs, + queryClass?: RagQueryClass, + queryVariants: string[] = [], +): Promise<{ results: SearchResult[]; telemetry: SearchTelemetry } | null> { + if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return null; + + const key = scopedSearchCacheKey(args, queryClass, queryVariants); + const cached = searchCache.get(key); + if (!cached) return null; + if (cached.expiresAt <= Date.now()) { + searchCache.delete(key); + return null; + } + const indexingVersion = await cacheIndexingVersion(args); + if (cached.indexingVersion !== indexingVersion) { + searchCache.delete(key); + return null; + } + + return { + results: cloneSearchResults(cached.results), + telemetry: { + ...cached.telemetry, + search_cache_hit: true, + retrieval_strategy: "search_cache" as const, + text_fast_path_latency_ms: 0, + embedding_latency_ms: 0, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + shared_cache_hit: false, + shared_cache_status: undefined, + shared_cache_miss_reason: null, + }, + }; +} + +export async function setCachedSearch( + args: SearchChunksArgs, + results: SearchResult[], + telemetry: SearchTelemetry, + queryVariants: string[] = [], +): Promise { + if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return; + const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry); + + const indexingVersion = await cacheIndexingVersion(args); + const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants); + searchCache.set(key, { + expiresAt: Date.now() + env.RAG_SEARCH_CACHE_TTL_MS, + results: cloneSearchResults(results), + telemetry: { ...cacheTelemetry }, + indexingVersion, + }); + + while (searchCache.size > env.RAG_SEARCH_CACHE_SIZE) { + const oldestKey = searchCache.keys().next().value; + if (!oldestKey) break; + searchCache.delete(oldestKey); + } + setSharedCachedSearch(args, results, cacheTelemetry, queryVariants); +} + +type SharedCacheKind = "search" | "answer"; +type SharedCacheMissReason = + | "cache_lookup_error" + | "cache_lookup_exception" + | "cache_payload_invalid" + | "no_entry" + | "expired" + | "indexing_version_mismatch" + | "dependency_version_mismatch" + | "unknown_filter_miss"; + +function sharedCacheSelector( + supabase: ReturnType, + kind: SharedCacheKind, + args: Pick, + indexingVersion: string, + normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), +) { + let query = supabase + .from("rag_response_cache") + .select("payload") + .eq("cache_kind", kind) + .eq("scope_key", scopeKey(args)) + .eq("normalized_query", normalizedQuery) + .eq("indexing_version", indexingVersion) + .eq("dependency_version", ragCacheDependencyVersion) + .gt("expires_at", new Date().toISOString()) + .limit(1); + + query = args.ownerId ? query.eq("owner_id", args.ownerId) : query.is("owner_id", null); + return query; +} + +export async function cacheIndexingVersion(args: Pick) { + const cacheKey = cacheIndexingVersionCacheKey(args); + const cached = cacheIndexingVersionCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.value; + + let value = `${ragDeepMemoryVersion}:index-stamp-unavailable`; + try { + const supabase = createAdminClient(); + const documentFilters = args.documentIds?.length ? args.documentIds : args.documentId ? [args.documentId] : null; + let query = supabase + .from("documents") + .select("id,updated_at,metadata") + .eq("status", "indexed") + .order("updated_at", { ascending: false }) + .limit(1); + if (args.ownerId) query = query.eq("owner_id", args.ownerId); + if (documentFilters?.length) query = query.in("id", documentFilters); + const { data, error } = await query; + if (error || !data?.length) { + value = `${ragDeepMemoryVersion}:no-indexed-documents`; + } else { + const latest = data[0] as { id?: string; updated_at?: string | null; metadata?: unknown }; + const metadata = normalizeSourceMetadata(latest.metadata); + const indexedAt = metadata.indexed_at ?? latest.updated_at ?? "unknown"; + const generationId = + latest.metadata && typeof latest.metadata === "object" && "index_generation_id" in latest.metadata + ? String((latest.metadata as { index_generation_id?: unknown }).index_generation_id ?? "") + : ""; + value = `${ragDeepMemoryVersion}:${latest.id ?? "all"}:${indexedAt}:${generationId}`; + } + } catch { + value = `${ragDeepMemoryVersion}:index-stamp-unavailable`; + } + cacheIndexingVersionCache.set(cacheKey, { value, expiresAt: Date.now() + cacheIndexingVersionTtlMs }); + return value; +} + +export async function getSharedCachedSearch( + args: SearchChunksArgs, + queryClass?: RagQueryClass, + queryVariants: string[] = [], +): Promise< + | { kind: "hit"; results: SearchResult[]; telemetry: SearchTelemetry } + | { kind: "miss"; reason: SharedCacheMissReason } + | null +> { + if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return null; + const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants); + const indexingVersion = await cacheIndexingVersion(args); + async function probeSharedCacheMissReason(reasonFromLookup?: SharedCacheMissReason): Promise { + if (reasonFromLookup) return reasonFromLookup; + try { + const supabase = createAdminClient(); + let probeQuery = supabase + .from("rag_response_cache") + .select("indexing_version,dependency_version,expires_at") + .eq("cache_kind", "search") + .eq("scope_key", scopeKey(args)) + .eq("normalized_query", normalizedQuery) + .order("expires_at", { ascending: false }) + .limit(5); + probeQuery = args.ownerId ? probeQuery.eq("owner_id", args.ownerId) : probeQuery.is("owner_id", null); + const { data, error } = await probeQuery; + if (error) return "cache_lookup_error"; + if (!data?.length) return "no_entry"; + const now = Date.now(); + const nonExpired = data.find((entry) => { + const expiresAt = Date.parse(String(entry.expires_at ?? "")); + return Number.isFinite(expiresAt) && expiresAt > now; + }); + if (!nonExpired) return "expired"; + if (String(nonExpired.indexing_version ?? "") !== indexingVersion) return "indexing_version_mismatch"; + if (String(nonExpired.dependency_version ?? "") !== ragCacheDependencyVersion) { + return "dependency_version_mismatch"; + } + return "unknown_filter_miss"; + } catch { + return "cache_lookup_exception"; + } + } + try { + const { data, error } = await sharedCacheSelector( + createAdminClient(), + "search", + args, + indexingVersion, + normalizedQuery, + ).maybeSingle(); + if (error) return { kind: "miss", reason: await probeSharedCacheMissReason("cache_lookup_error") }; + if (!data?.payload) return { kind: "miss", reason: await probeSharedCacheMissReason() }; + const payload = data.payload as { results?: SearchResult[]; telemetry?: Partial }; + if (!Array.isArray(payload.results)) { + return { kind: "miss", reason: await probeSharedCacheMissReason("cache_payload_invalid") }; + } + return { + kind: "hit", + results: cloneSearchResults(payload.results), + telemetry: { + search_cache_hit: true, + shared_cache_hit: true, + shared_cache_status: "hit", + shared_cache_miss_reason: null, + query_class: payload.telemetry?.query_class, + vector_candidate_count: payload.telemetry?.vector_candidate_count, + text_candidate_count: payload.telemetry?.text_candidate_count, + embedding_field_count: payload.telemetry?.embedding_field_count, + retrieval_query_variant_count: payload.telemetry?.retrieval_query_variant_count ?? 0, + text_fast_path_latency_ms: 0, + text_candidate_budget: payload.telemetry?.text_candidate_budget, + text_fast_path_reason: payload.telemetry?.text_fast_path_reason ?? null, + embedding_skipped: true, + embedding_skip_reason: payload.telemetry?.embedding_skip_reason ?? "search_cache", + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + memory_card_count: payload.telemetry?.memory_card_count ?? 0, + memory_top_score: payload.telemetry?.memory_top_score ?? 0, + index_unit_count: payload.telemetry?.index_unit_count ?? 0, + index_unit_top_score: payload.telemetry?.index_unit_top_score ?? 0, + retrieval_plan: payload.telemetry?.retrieval_plan ?? retrievalPlanForQueryClass(payload.telemetry?.query_class), + retrieval_intent: payload.telemetry?.retrieval_intent, + retrieval_selection: payload.telemetry?.retrieval_selection, + retrieval_layer_counts: payload.telemetry?.retrieval_layer_counts ?? {}, + retrieval_layer_top_scores: payload.telemetry?.retrieval_layer_top_scores ?? {}, + retrieval_layer_latencies_ms: payload.telemetry?.retrieval_layer_latencies_ms ?? {}, + retrieval_provenance_counts: payload.telemetry?.retrieval_provenance_counts ?? {}, + coverage_gate_decision: payload.telemetry?.coverage_gate_decision, + coverage_gate_reason: payload.telemetry?.coverage_gate_reason ?? null, + vector_skipped_reason: payload.telemetry?.vector_skipped_reason ?? null, + source_image_required: payload.telemetry?.source_image_required ?? false, + source_image_satisfied: payload.telemetry?.source_image_satisfied ?? false, + second_stage_rerank_used: payload.telemetry?.second_stage_rerank_used ?? false, + second_stage_rerank_latency_ms: 0, + visual_direct_image_count: payload.telemetry?.visual_direct_image_count ?? 0, + weighted_top_score: payload.telemetry?.weighted_top_score ?? 0, + rrf_top_score: payload.telemetry?.rrf_top_score ?? 0, + retrieval_strategy: "search_cache" as const, + }, + }; + } catch { + return { kind: "miss", reason: "cache_lookup_exception" }; + } +} + +export async function getSharedCachedAnswer( + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, + startedAt: number, +) { + if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; + try { + const indexingVersion = await cacheIndexingVersion(args); + const { data, error } = await sharedCacheSelector( + createAdminClient(), + "answer", + args, + indexingVersion, + ).maybeSingle(); + if (error || !data?.payload) return null; + const answer = cloneAnswer((data.payload as { answer: RagAnswer }).answer); + answer.routingReason = answer.routingReason + ? `${answer.routingReason}; shared_answer_cache_hit` + : "shared_answer_cache_hit"; + answer.latencyTimings = { + ...answer.latencyTimings, + search_cache_hit: true, + shared_cache_hit: true, + shared_cache_status: "hit", + shared_cache_miss_reason: null, + total_latency_ms: Date.now() - startedAt, + }; + return answer; + } catch { + return null; + } +} + +async function replaceSharedCacheRow( + kind: SharedCacheKind, + args: Pick, + payload: unknown, + ttlMs: number, + normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), +) { + if (ttlMs <= 0) return; + try { + const supabase = createAdminClient(); + const indexingVersion = await cacheIndexingVersion(args); + let deleteQuery = supabase + .from("rag_response_cache") + .delete() + .eq("cache_kind", kind) + .eq("scope_key", scopeKey(args)) + .eq("normalized_query", normalizedQuery) + .eq("indexing_version", indexingVersion) + .eq("dependency_version", ragCacheDependencyVersion); + deleteQuery = args.ownerId ? deleteQuery.eq("owner_id", args.ownerId) : deleteQuery.is("owner_id", null); + await deleteQuery; + await supabase.from("rag_response_cache").insert({ + owner_id: args.ownerId ?? null, + cache_kind: kind, + scope_key: scopeKey(args), + normalized_query: normalizedQuery, + indexing_version: indexingVersion, + dependency_version: ragCacheDependencyVersion, + // JSON-serializable by contract of the response cache. + payload: payload as Json, + expires_at: new Date(Date.now() + ttlMs).toISOString(), + }); + } catch { + // Shared cache must never be part of the correctness path. + } +} + +function setSharedCachedSearch( + args: SearchChunksArgs, + results: SearchResult[], + telemetry: SearchTelemetry, + queryVariants: string[] = [], +) { + if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return; + void replaceSharedCacheRow( + "search", + args, + { results: cloneSearchResults(results), telemetry }, + env.RAG_SEARCH_CACHE_TTL_MS, + retrievalPlanCacheQuery(args, telemetry.query_class, queryVariants), + ); +} + +function setSharedCachedAnswer( + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, + answer: RagAnswer, +) { + if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; + void replaceSharedCacheRow("answer", args, { answer: cloneAnswer(answer) }, env.RAG_ANSWER_CACHE_TTL_MS); +} + +export function invalidateRagCachesForOwner(ownerId?: string | null) { + if (!ownerId) { + answerCache.clear(); + answerInflight.clear(); + searchCache.clear(); + cacheIndexingVersionCache.clear(); + void (async () => { + try { + await createAdminClient().from("rag_response_cache").delete().in("cache_kind", ["search", "answer"]); + } catch (error) { + // Shared cache invalidation is best effort. + console.warn("Shared cache invalidation failed (all kinds):", error); + } + })(); + return; + } + + const sharedCacheOwnerId = ownerId === "anonymous" ? null : ownerId; + for (const key of answerCache.keys()) { + if (ragCacheKeyMatchesOwner(key, ownerId)) answerCache.delete(key); + } + for (const key of answerInflight.keys()) { + if (ragCacheKeyMatchesOwner(key, ownerId)) answerInflight.delete(key); + } + for (const key of searchCache.keys()) { + if (ragCacheKeyMatchesOwner(key, ownerId)) searchCache.delete(key); + } + for (const key of cacheIndexingVersionCache.keys()) { + if (ragCacheKeyMatchesOwner(key, ownerId)) cacheIndexingVersionCache.delete(key); + } + void (async () => { + try { + const deleteQuery = createAdminClient().from("rag_response_cache").delete(); + const scopedQuery = sharedCacheOwnerId + ? deleteQuery.eq("owner_id", sharedCacheOwnerId) + : deleteQuery.is("owner_id", null); + await scopedQuery.in("cache_kind", ["search", "answer"]); + } catch (error) { + // Shared cache invalidation is best effort. + console.warn("Shared cache invalidation failed for owner:", error); + } + })(); +} + +function invalidateAnonymousSharedRagCaches() { + void (async () => { + try { + await createAdminClient() + .from("rag_response_cache") + .delete() + .is("owner_id", null) + .in("cache_kind", ["search", "answer"]); + } catch (error) { + // Shared cache invalidation is best effort. + console.warn("Shared cache invalidation failed for anonymous:", error); + } + })(); +} + +export function invalidateRagCachesForDocumentMutation(ownerId: string) { + invalidateRagCachesForOwner(ownerId); + invalidateAnonymousSharedRagCaches(); +} + +function sourceContextPackLimit(queryClass: RagQueryClass, options: { crossDocument?: boolean } = {}) { + return options.crossDocument || queryClass === "comparison" || queryClass === "broad_summary" ? 8 : 5; +} + +export function packedContextCacheKey( + results: SearchResult[], + queryClass: RagQueryClass, + options: { crossDocument?: boolean; documentIds?: string[] } = {}, +) { + const contextLimit = sourceContextPackLimit(queryClass, options); + const scopeKey = options.documentIds?.length + ? stableHash([...new Set(options.documentIds)].sort().join("|")) + : "all-documents"; + return [ + queryClass, + options.crossDocument ? "cross-document" : "single-document", + `scope:${scopeKey}`, + contextLimit, + ...results + .slice(0, contextLimit) + .map((result) => `${result.id}:${result.document_id}:${result.chunk_index}:${result.page_number ?? "na"}`), + ].join("|"); +} + +export async function packAdjacentSourceContext( + supabase: ReturnType, + results: SearchResult[], + queryClass: RagQueryClass, + options: { crossDocument?: boolean } = {}, +) { + const contextLimit = sourceContextPackLimit(queryClass, options); + const targetResults = results.slice(0, contextLimit); + const documentIds = Array.from(new Set(targetResults.map((result) => result.document_id))); + const chunkIndexes = Array.from( + new Set( + targetResults.flatMap((result) => [result.chunk_index - 1, result.chunk_index + 1]).filter((index) => index >= 0), + ), + ); + if (documentIds.length === 0 || chunkIndexes.length === 0) return results; + + try { + const { data, error } = await supabase + .from("document_chunks") + .select("id,document_id,page_number,chunk_index,section_heading,content,retrieval_synopsis,index_generation_id") + .in("document_id", documentIds) + .in("chunk_index", chunkIndexes) + .order("chunk_index", { ascending: true }) + .limit(80); + + if (error || !data?.length) return results; + + const chunksByDocumentAndIndex = new Map< + string, + { id: string; section_heading: string | null; content: string; retrieval_synopsis?: string | null } + >(); + const committedGenerationByDocument = new Map( + targetResults.map((result) => [result.document_id, committedIndexGeneration(result.source_metadata)] as const), + ); + for (const chunk of data) { + const committedGeneration = committedGenerationByDocument.get(chunk.document_id); + if (chunk.index_generation_id && chunk.index_generation_id !== committedGeneration) continue; + chunksByDocumentAndIndex.set(`${chunk.document_id}:${chunk.chunk_index}`, { + id: chunk.id, + section_heading: chunk.section_heading, + content: chunk.content, + retrieval_synopsis: chunk.retrieval_synopsis ?? null, + }); + } + + const targetIds = new Set(targetResults.map((result) => result.id)); + return results.map((result) => { + if (!targetIds.has(result.id)) return result; + const adjacent = [result.chunk_index - 1, result.chunk_index + 1] + .map((index) => chunksByDocumentAndIndex.get(`${result.document_id}:${index}`)) + .filter( + ( + chunk, + ): chunk is { + id: string; + section_heading: string | null; + content: string; + retrieval_synopsis?: string | null; + } => Boolean(chunk && chunk.id !== result.id && chunk.content.trim()), + ) + .map((chunk) => { + const heading = chunk.section_heading ? `${chunk.section_heading}: ` : ""; + return compactContextText(`${heading}${chunk.retrieval_synopsis || chunk.content}`, 520); + }); + + if (adjacent.length === 0) return result; + return { + ...result, + adjacent_context: adjacent.join(" "), + }; + }); + } catch { + return results; + } +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 140dba75fa..29db1d53a8 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -63,6 +63,26 @@ export { shouldRelaxWeakTextMatches, textCandidateBudgetForQueryClass, } from "@/lib/rag-retrieval-variants"; +import { + answerInflight, + cacheIndexingVersion, + cloneAnswer, + getCachedAnswer, + getCachedSearch, + getSharedCachedAnswer, + getSharedCachedSearch, + packAdjacentSourceContext, + packedContextCacheKey, + scopedAnswerCacheKey, + setCachedAnswer, + setCachedSearch, +} from "@/lib/rag-cache"; +export { + invalidateRagCachesForDocumentMutation, + invalidateRagCachesForOwner, + packedContextCacheKey, + retrievalPlanCacheQuery, +} from "@/lib/rag-cache"; import { buildRagSourceBlock, compactContextText } from "@/lib/rag-source-block"; export { buildRagSourceBlock, truncateForModel } from "@/lib/rag-source-block"; import { extractNumericTokens, VERIFY_AGAINST_SOURCE_NOTE, verifyAnswerNumbers } from "@/lib/answer-verification"; @@ -81,8 +101,7 @@ import { } from "@/lib/clinical-search"; import { env, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; -import { queryCacheKeyForStorage, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; -import { ragCacheKeyMatchesOwner } from "@/lib/rag-cache-utils"; +import { queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; import { normalizeSourceMetadata } from "@/lib/source-metadata"; import { isReviewedTablePromotable } from "@/lib/table-review"; import { isClinicalImageEvidence, normalizeImageBbox } from "@/lib/image-filtering"; @@ -127,7 +146,6 @@ import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/r import { buildRetrievalIntent, selectRetrievalEvidence } from "@/lib/retrieval-selection"; import { rankingConfig } from "@/lib/ranking-config"; import { z } from "zod"; -import { createHash } from "node:crypto"; import { buildDocumentBreakdown, buildEvidenceSummary, @@ -450,7 +468,7 @@ export type SearchTelemetry = { | "unsupported_short_circuit"; }; -function retrievalPlanForQueryClass(queryClass?: RagQueryClass) { +export function retrievalPlanForQueryClass(queryClass?: RagQueryClass) { switch (queryClass) { case "document_lookup": return "document_lookup:title_label_section_then_chunks"; @@ -1071,16 +1089,6 @@ export function fallbackReasonFromRouting(reason?: string | null) { ); } -const answerCache = new Map(); -const answerInflight = new Map>(); -const searchCache = new Map< - string, - { expiresAt: number; results: SearchResult[]; telemetry: SearchTelemetry; indexingVersion: string } ->(); -const ragCacheDependencyVersion = "rag-cache-v12"; -const cacheIndexingVersionTtlMs = 5000; -const cacheIndexingVersionCache = new Map(); - const clearlyNonClinicalConsumerPattern = /\b(coffee\s*machine|espresso|kitchen|recipe|holiday|hotel|restaurant|car|mortgage|insurance|gaming|laptop|phone|television|tv|washing\s*machine|air\s*fryer|vacuum|flight|airline)\b/i; const clearlyOutsideCorpusMedicalPattern = @@ -1366,23 +1374,6 @@ function isUnsupportedSoftTailAnalysis(query: string, analysis: ClinicalQueryAna return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; } -function scopeKey(args: Pick) { - const scope = args.documentIds?.length - ? [...args.documentIds].sort().join(",") - : args.documentId - ? args.documentId - : "all-documents"; - return scope; -} - -function normalizedCacheQuery(query: string) { - return buildClinicalTextSearchQuery(query).toLowerCase().replace(/\s+/g, " ").trim(); -} - -function cacheIndexingVersionCacheKey(args: Pick) { - return [args.ownerId ?? "anonymous", scopeKey(args)].join("|"); -} - function metadataExpansionTermScore(queryTokens: Set, value: string, sourceWeight: number) { const tokens = normalizedClinicalSearchTokens(value); if (tokens.length === 0) return 0; @@ -1439,539 +1430,6 @@ function expandClinicalQueryWithCandidateMetadata(query: string, expandedQuery: return uniqueTextValues([expandedQuery, ...metadataTerms], 24).join(" "); } -function modeKey(args: Pick) { - return args.queryMode ?? "auto"; -} - -function scopedAnswerCacheKey( - args: Pick, -) { - return [ - ragCacheDependencyVersion, - args.ownerId ?? "anonymous", - scopeKey(args), - modeKey(args), - args.query.trim().toLowerCase().replace(/\s+/g, " "), - ].join("|"); -} - -function cloneAnswer(answer: RagAnswer) { - return structuredClone(answer); -} - -async function getCachedAnswer( - args: Pick, - startedAt: number, -): Promise { - if (args.skipCache) return null; - if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return null; - - const key = scopedAnswerCacheKey(args); - const cached = answerCache.get(key); - if (!cached) return null; - if (cached.expiresAt <= Date.now()) { - answerCache.delete(key); - return null; - } - const indexingVersion = await cacheIndexingVersion(args); - if (cached.indexingVersion !== indexingVersion) { - answerCache.delete(key); - return null; - } - - const answer = cloneAnswer(cached.answer); - answer.routingReason = answer.routingReason ? `${answer.routingReason}; answer_cache_hit` : "answer_cache_hit"; - answer.latencyTimings = { - ...answer.latencyTimings, - total_latency_ms: Date.now() - startedAt, - }; - return answer; -} - -async function setCachedAnswer( - args: Pick, - answer: RagAnswer, - options?: { indexingVersionAtRetrievalStart?: string | null }, -): Promise { - if (args.skipCache) return; - if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return; - - if (options?.indexingVersionAtRetrievalStart) { - const currentIndexingVersion = await cacheIndexingVersion(args); - if (currentIndexingVersion !== options.indexingVersionAtRetrievalStart) return; - } - - const indexingVersion = await cacheIndexingVersion(args); - const key = scopedAnswerCacheKey(args); - answerCache.set(key, { - expiresAt: Date.now() + env.RAG_ANSWER_CACHE_TTL_MS, - answer: cloneAnswer(answer), - indexingVersion, - }); - - while (answerCache.size > env.RAG_ANSWER_CACHE_SIZE) { - const oldestKey = answerCache.keys().next().value; - if (!oldestKey) break; - answerCache.delete(oldestKey); - } - setSharedCachedAnswer(args, answer); -} - -function stableHash(value: string) { - return createHash("sha256").update(value).digest("hex").slice(0, 16); -} - -export function retrievalPlanCacheQuery( - args: Pick< - SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding" - >, - queryClass?: RagQueryClass, - queryVariants: string[] = [], -) { - const normalizedQuery = normalizedCacheQuery(args.query); - const variantHash = stableHash(queryVariants.join("\n")); - const cacheKey = [ - `plan:${retrievalPlanForQueryClass(queryClass)}`, - `class:${queryClass ?? "unknown"}`, - `query:${normalizedQuery}`, - `variants:${variantHash}`, - `mode:${modeKey(args)}`, - `topK:${args.topK ?? 8}`, - `min:${args.minSimilarity ?? 0.15}`, - `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, - `rag:${ragDeepMemoryVersion}`, - `force:${args.forceEmbedding ? 1 : 0}`, - ].join("|"); - return queryCacheKeyForStorage(cacheKey); -} - -function scopedSearchCacheKey(args: SearchChunksArgs, queryClass?: RagQueryClass, queryVariants: string[] = []) { - return [ - ragCacheDependencyVersion, - args.ownerId ?? "anonymous", - scopeKey(args), - retrievalPlanCacheQuery(args, queryClass, queryVariants), - ].join("|"); -} - -function cloneSearchResults(results: SearchResult[]) { - return structuredClone(results); -} - -function normalizeCacheStorageTelemetry(telemetry: SearchTelemetry): SearchTelemetry { - return { - ...telemetry, - shared_cache_hit: false, - shared_cache_status: undefined, - shared_cache_miss_reason: null, - }; -} - -async function getCachedSearch( - args: SearchChunksArgs, - queryClass?: RagQueryClass, - queryVariants: string[] = [], -): Promise<{ results: SearchResult[]; telemetry: SearchTelemetry } | null> { - if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return null; - - const key = scopedSearchCacheKey(args, queryClass, queryVariants); - const cached = searchCache.get(key); - if (!cached) return null; - if (cached.expiresAt <= Date.now()) { - searchCache.delete(key); - return null; - } - const indexingVersion = await cacheIndexingVersion(args); - if (cached.indexingVersion !== indexingVersion) { - searchCache.delete(key); - return null; - } - - return { - results: cloneSearchResults(cached.results), - telemetry: { - ...cached.telemetry, - search_cache_hit: true, - retrieval_strategy: "search_cache" as const, - text_fast_path_latency_ms: 0, - embedding_latency_ms: 0, - supabase_rpc_latency_ms: 0, - rerank_latency_ms: 0, - shared_cache_hit: false, - shared_cache_status: undefined, - shared_cache_miss_reason: null, - }, - }; -} - -async function setCachedSearch( - args: SearchChunksArgs, - results: SearchResult[], - telemetry: SearchTelemetry, - queryVariants: string[] = [], -): Promise { - if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return; - const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry); - - const indexingVersion = await cacheIndexingVersion(args); - const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants); - searchCache.set(key, { - expiresAt: Date.now() + env.RAG_SEARCH_CACHE_TTL_MS, - results: cloneSearchResults(results), - telemetry: { ...cacheTelemetry }, - indexingVersion, - }); - - while (searchCache.size > env.RAG_SEARCH_CACHE_SIZE) { - const oldestKey = searchCache.keys().next().value; - if (!oldestKey) break; - searchCache.delete(oldestKey); - } - setSharedCachedSearch(args, results, cacheTelemetry, queryVariants); -} - -type SharedCacheKind = "search" | "answer"; -type SharedCacheMissReason = - | "cache_lookup_error" - | "cache_lookup_exception" - | "cache_payload_invalid" - | "no_entry" - | "expired" - | "indexing_version_mismatch" - | "dependency_version_mismatch" - | "unknown_filter_miss"; - -function sharedCacheSelector( - supabase: ReturnType, - kind: SharedCacheKind, - args: Pick, - indexingVersion: string, - normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), -) { - let query = supabase - .from("rag_response_cache") - .select("payload") - .eq("cache_kind", kind) - .eq("scope_key", scopeKey(args)) - .eq("normalized_query", normalizedQuery) - .eq("indexing_version", indexingVersion) - .eq("dependency_version", ragCacheDependencyVersion) - .gt("expires_at", new Date().toISOString()) - .limit(1); - - query = args.ownerId ? query.eq("owner_id", args.ownerId) : query.is("owner_id", null); - return query; -} - -async function cacheIndexingVersion(args: Pick) { - const cacheKey = cacheIndexingVersionCacheKey(args); - const cached = cacheIndexingVersionCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.value; - - let value = `${ragDeepMemoryVersion}:index-stamp-unavailable`; - try { - const supabase = createAdminClient(); - const documentFilters = args.documentIds?.length ? args.documentIds : args.documentId ? [args.documentId] : null; - let query = supabase - .from("documents") - .select("id,updated_at,metadata") - .eq("status", "indexed") - .order("updated_at", { ascending: false }) - .limit(1); - if (args.ownerId) query = query.eq("owner_id", args.ownerId); - if (documentFilters?.length) query = query.in("id", documentFilters); - const { data, error } = await query; - if (error || !data?.length) { - value = `${ragDeepMemoryVersion}:no-indexed-documents`; - } else { - const latest = data[0] as { id?: string; updated_at?: string | null; metadata?: unknown }; - const metadata = normalizeSourceMetadata(latest.metadata); - const indexedAt = metadata.indexed_at ?? latest.updated_at ?? "unknown"; - const generationId = - latest.metadata && typeof latest.metadata === "object" && "index_generation_id" in latest.metadata - ? String((latest.metadata as { index_generation_id?: unknown }).index_generation_id ?? "") - : ""; - value = `${ragDeepMemoryVersion}:${latest.id ?? "all"}:${indexedAt}:${generationId}`; - } - } catch { - value = `${ragDeepMemoryVersion}:index-stamp-unavailable`; - } - cacheIndexingVersionCache.set(cacheKey, { value, expiresAt: Date.now() + cacheIndexingVersionTtlMs }); - return value; -} - -async function getSharedCachedSearch( - args: SearchChunksArgs, - queryClass?: RagQueryClass, - queryVariants: string[] = [], -): Promise< - | { kind: "hit"; results: SearchResult[]; telemetry: SearchTelemetry } - | { kind: "miss"; reason: SharedCacheMissReason } - | null -> { - if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return null; - const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants); - const indexingVersion = await cacheIndexingVersion(args); - async function probeSharedCacheMissReason(reasonFromLookup?: SharedCacheMissReason): Promise { - if (reasonFromLookup) return reasonFromLookup; - try { - const supabase = createAdminClient(); - let probeQuery = supabase - .from("rag_response_cache") - .select("indexing_version,dependency_version,expires_at") - .eq("cache_kind", "search") - .eq("scope_key", scopeKey(args)) - .eq("normalized_query", normalizedQuery) - .order("expires_at", { ascending: false }) - .limit(5); - probeQuery = args.ownerId ? probeQuery.eq("owner_id", args.ownerId) : probeQuery.is("owner_id", null); - const { data, error } = await probeQuery; - if (error) return "cache_lookup_error"; - if (!data?.length) return "no_entry"; - const now = Date.now(); - const nonExpired = data.find((entry) => { - const expiresAt = Date.parse(String(entry.expires_at ?? "")); - return Number.isFinite(expiresAt) && expiresAt > now; - }); - if (!nonExpired) return "expired"; - if (String(nonExpired.indexing_version ?? "") !== indexingVersion) return "indexing_version_mismatch"; - if (String(nonExpired.dependency_version ?? "") !== ragCacheDependencyVersion) { - return "dependency_version_mismatch"; - } - return "unknown_filter_miss"; - } catch { - return "cache_lookup_exception"; - } - } - try { - const { data, error } = await sharedCacheSelector( - createAdminClient(), - "search", - args, - indexingVersion, - normalizedQuery, - ).maybeSingle(); - if (error) return { kind: "miss", reason: await probeSharedCacheMissReason("cache_lookup_error") }; - if (!data?.payload) return { kind: "miss", reason: await probeSharedCacheMissReason() }; - const payload = data.payload as { results?: SearchResult[]; telemetry?: Partial }; - if (!Array.isArray(payload.results)) { - return { kind: "miss", reason: await probeSharedCacheMissReason("cache_payload_invalid") }; - } - return { - kind: "hit", - results: cloneSearchResults(payload.results), - telemetry: { - search_cache_hit: true, - shared_cache_hit: true, - shared_cache_status: "hit", - shared_cache_miss_reason: null, - query_class: payload.telemetry?.query_class, - vector_candidate_count: payload.telemetry?.vector_candidate_count, - text_candidate_count: payload.telemetry?.text_candidate_count, - embedding_field_count: payload.telemetry?.embedding_field_count, - retrieval_query_variant_count: payload.telemetry?.retrieval_query_variant_count ?? 0, - text_fast_path_latency_ms: 0, - text_candidate_budget: payload.telemetry?.text_candidate_budget, - text_fast_path_reason: payload.telemetry?.text_fast_path_reason ?? null, - embedding_skipped: true, - embedding_skip_reason: payload.telemetry?.embedding_skip_reason ?? "search_cache", - embedding_latency_ms: 0, - embedding_cache_hit: false, - supabase_rpc_latency_ms: 0, - rerank_latency_ms: 0, - memory_card_count: payload.telemetry?.memory_card_count ?? 0, - memory_top_score: payload.telemetry?.memory_top_score ?? 0, - index_unit_count: payload.telemetry?.index_unit_count ?? 0, - index_unit_top_score: payload.telemetry?.index_unit_top_score ?? 0, - retrieval_plan: payload.telemetry?.retrieval_plan ?? retrievalPlanForQueryClass(payload.telemetry?.query_class), - retrieval_intent: payload.telemetry?.retrieval_intent, - retrieval_selection: payload.telemetry?.retrieval_selection, - retrieval_layer_counts: payload.telemetry?.retrieval_layer_counts ?? {}, - retrieval_layer_top_scores: payload.telemetry?.retrieval_layer_top_scores ?? {}, - retrieval_layer_latencies_ms: payload.telemetry?.retrieval_layer_latencies_ms ?? {}, - retrieval_provenance_counts: payload.telemetry?.retrieval_provenance_counts ?? {}, - coverage_gate_decision: payload.telemetry?.coverage_gate_decision, - coverage_gate_reason: payload.telemetry?.coverage_gate_reason ?? null, - vector_skipped_reason: payload.telemetry?.vector_skipped_reason ?? null, - source_image_required: payload.telemetry?.source_image_required ?? false, - source_image_satisfied: payload.telemetry?.source_image_satisfied ?? false, - second_stage_rerank_used: payload.telemetry?.second_stage_rerank_used ?? false, - second_stage_rerank_latency_ms: 0, - visual_direct_image_count: payload.telemetry?.visual_direct_image_count ?? 0, - weighted_top_score: payload.telemetry?.weighted_top_score ?? 0, - rrf_top_score: payload.telemetry?.rrf_top_score ?? 0, - retrieval_strategy: "search_cache" as const, - }, - }; - } catch { - return { kind: "miss", reason: "cache_lookup_exception" }; - } -} - -async function getSharedCachedAnswer( - args: Pick< - SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" - >, - startedAt: number, -) { - if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; - try { - const indexingVersion = await cacheIndexingVersion(args); - const { data, error } = await sharedCacheSelector( - createAdminClient(), - "answer", - args, - indexingVersion, - ).maybeSingle(); - if (error || !data?.payload) return null; - const answer = cloneAnswer((data.payload as { answer: RagAnswer }).answer); - answer.routingReason = answer.routingReason - ? `${answer.routingReason}; shared_answer_cache_hit` - : "shared_answer_cache_hit"; - answer.latencyTimings = { - ...answer.latencyTimings, - search_cache_hit: true, - shared_cache_hit: true, - shared_cache_status: "hit", - shared_cache_miss_reason: null, - total_latency_ms: Date.now() - startedAt, - }; - return answer; - } catch { - return null; - } -} - -async function replaceSharedCacheRow( - kind: SharedCacheKind, - args: Pick, - payload: unknown, - ttlMs: number, - normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), -) { - if (ttlMs <= 0) return; - try { - const supabase = createAdminClient(); - const indexingVersion = await cacheIndexingVersion(args); - let deleteQuery = supabase - .from("rag_response_cache") - .delete() - .eq("cache_kind", kind) - .eq("scope_key", scopeKey(args)) - .eq("normalized_query", normalizedQuery) - .eq("indexing_version", indexingVersion) - .eq("dependency_version", ragCacheDependencyVersion); - deleteQuery = args.ownerId ? deleteQuery.eq("owner_id", args.ownerId) : deleteQuery.is("owner_id", null); - await deleteQuery; - await supabase.from("rag_response_cache").insert({ - owner_id: args.ownerId ?? null, - cache_kind: kind, - scope_key: scopeKey(args), - normalized_query: normalizedQuery, - indexing_version: indexingVersion, - dependency_version: ragCacheDependencyVersion, - // JSON-serializable by contract of the response cache. - payload: payload as Json, - expires_at: new Date(Date.now() + ttlMs).toISOString(), - }); - } catch { - // Shared cache must never be part of the correctness path. - } -} - -function setSharedCachedSearch( - args: SearchChunksArgs, - results: SearchResult[], - telemetry: SearchTelemetry, - queryVariants: string[] = [], -) { - if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return; - void replaceSharedCacheRow( - "search", - args, - { results: cloneSearchResults(results), telemetry }, - env.RAG_SEARCH_CACHE_TTL_MS, - retrievalPlanCacheQuery(args, telemetry.query_class, queryVariants), - ); -} - -function setSharedCachedAnswer( - args: Pick< - SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" - >, - answer: RagAnswer, -) { - if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; - void replaceSharedCacheRow("answer", args, { answer: cloneAnswer(answer) }, env.RAG_ANSWER_CACHE_TTL_MS); -} - -export function invalidateRagCachesForOwner(ownerId?: string | null) { - if (!ownerId) { - answerCache.clear(); - answerInflight.clear(); - searchCache.clear(); - cacheIndexingVersionCache.clear(); - void (async () => { - try { - await createAdminClient().from("rag_response_cache").delete().in("cache_kind", ["search", "answer"]); - } catch (error) { - // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed (all kinds):", error); - } - })(); - return; - } - - const sharedCacheOwnerId = ownerId === "anonymous" ? null : ownerId; - for (const key of answerCache.keys()) { - if (ragCacheKeyMatchesOwner(key, ownerId)) answerCache.delete(key); - } - for (const key of answerInflight.keys()) { - if (ragCacheKeyMatchesOwner(key, ownerId)) answerInflight.delete(key); - } - for (const key of searchCache.keys()) { - if (ragCacheKeyMatchesOwner(key, ownerId)) searchCache.delete(key); - } - for (const key of cacheIndexingVersionCache.keys()) { - if (ragCacheKeyMatchesOwner(key, ownerId)) cacheIndexingVersionCache.delete(key); - } - void (async () => { - try { - const deleteQuery = createAdminClient().from("rag_response_cache").delete(); - const scopedQuery = sharedCacheOwnerId - ? deleteQuery.eq("owner_id", sharedCacheOwnerId) - : deleteQuery.is("owner_id", null); - await scopedQuery.in("cache_kind", ["search", "answer"]); - } catch (error) { - // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed for owner:", error); - } - })(); -} - -function invalidateAnonymousSharedRagCaches() { - void (async () => { - try { - await createAdminClient() - .from("rag_response_cache") - .delete() - .is("owner_id", null) - .in("cache_kind", ["search", "answer"]); - } catch (error) { - // Shared cache invalidation is best effort. - console.warn("Shared cache invalidation failed for anonymous:", error); - } - })(); -} - -export function invalidateRagCachesForDocumentMutation(ownerId: string) { - invalidateRagCachesForOwner(ownerId); - invalidateAnonymousSharedRagCaches(); -} - type RagQueryInsert = Omit & { metadata?: Record; }; @@ -2922,106 +2380,6 @@ async function attachIndexQualityMetadata( } } -function sourceContextPackLimit(queryClass: RagQueryClass, options: { crossDocument?: boolean } = {}) { - return options.crossDocument || queryClass === "comparison" || queryClass === "broad_summary" ? 8 : 5; -} - -export function packedContextCacheKey( - results: SearchResult[], - queryClass: RagQueryClass, - options: { crossDocument?: boolean; documentIds?: string[] } = {}, -) { - const contextLimit = sourceContextPackLimit(queryClass, options); - const scopeKey = options.documentIds?.length - ? stableHash([...new Set(options.documentIds)].sort().join("|")) - : "all-documents"; - return [ - queryClass, - options.crossDocument ? "cross-document" : "single-document", - `scope:${scopeKey}`, - contextLimit, - ...results - .slice(0, contextLimit) - .map((result) => `${result.id}:${result.document_id}:${result.chunk_index}:${result.page_number ?? "na"}`), - ].join("|"); -} - -async function packAdjacentSourceContext( - supabase: ReturnType, - results: SearchResult[], - queryClass: RagQueryClass, - options: { crossDocument?: boolean } = {}, -) { - const contextLimit = sourceContextPackLimit(queryClass, options); - const targetResults = results.slice(0, contextLimit); - const documentIds = Array.from(new Set(targetResults.map((result) => result.document_id))); - const chunkIndexes = Array.from( - new Set( - targetResults.flatMap((result) => [result.chunk_index - 1, result.chunk_index + 1]).filter((index) => index >= 0), - ), - ); - if (documentIds.length === 0 || chunkIndexes.length === 0) return results; - - try { - const { data, error } = await supabase - .from("document_chunks") - .select("id,document_id,page_number,chunk_index,section_heading,content,retrieval_synopsis,index_generation_id") - .in("document_id", documentIds) - .in("chunk_index", chunkIndexes) - .order("chunk_index", { ascending: true }) - .limit(80); - - if (error || !data?.length) return results; - - const chunksByDocumentAndIndex = new Map< - string, - { id: string; section_heading: string | null; content: string; retrieval_synopsis?: string | null } - >(); - const committedGenerationByDocument = new Map( - targetResults.map((result) => [result.document_id, committedIndexGeneration(result.source_metadata)] as const), - ); - for (const chunk of data) { - const committedGeneration = committedGenerationByDocument.get(chunk.document_id); - if (chunk.index_generation_id && chunk.index_generation_id !== committedGeneration) continue; - chunksByDocumentAndIndex.set(`${chunk.document_id}:${chunk.chunk_index}`, { - id: chunk.id, - section_heading: chunk.section_heading, - content: chunk.content, - retrieval_synopsis: chunk.retrieval_synopsis ?? null, - }); - } - - const targetIds = new Set(targetResults.map((result) => result.id)); - return results.map((result) => { - if (!targetIds.has(result.id)) return result; - const adjacent = [result.chunk_index - 1, result.chunk_index + 1] - .map((index) => chunksByDocumentAndIndex.get(`${result.document_id}:${index}`)) - .filter( - ( - chunk, - ): chunk is { - id: string; - section_heading: string | null; - content: string; - retrieval_synopsis?: string | null; - } => Boolean(chunk && chunk.id !== result.id && chunk.content.trim()), - ) - .map((chunk) => { - const heading = chunk.section_heading ? `${chunk.section_heading}: ` : ""; - return compactContextText(`${heading}${chunk.retrieval_synopsis || chunk.content}`, 520); - }); - - if (adjacent.length === 0) return result; - return { - ...result, - adjacent_context: adjacent.join(" "), - }; - }); - } catch { - return results; - } -} - async function attachPageVisualEvidence( supabase: ReturnType, results: SearchResult[], From edbc57c06416295ac0a22f2cdbe2ce52a434719e Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:49:22 +0800 Subject: [PATCH 6/6] docs: record part-2 move 3 (cache + context-packing) + eval-owner/corpus data blocker root cause Co-Authored-By: Claude Opus 4.8 --- docs/process-hardening.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 5e1f930f36..89157972aa 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -211,7 +211,8 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **Move 1 (shipped): L+M → `rag-extractive-answer.ts`.** Lines 3954–5503 moved verbatim (empty block diff modulo the `export` keyword added to 9 functions that now cross the boundary). Extracting L+M **together** keeps their mutual entanglement internal → no cross-module cycle between them. rag.ts imports the 12 names it still calls, re-exports the 6 tests import. Per the sanctioned "accept back-edges" path, the module imports **9 call-time helpers back from rag.ts** (`evidenceTextForGate`, `rankMemoryCardsForAnswer`, `deriveConfidence`, `scoreValue`, `machineReadableFallbackAnswer`, `buildAnswerScoreExplanations`, `buildIndexingQuality`, `collectMemoryCards`, `fallbackReasonFromRouting`) — a rag.ts↔module cycle that is runtime-safe (all uses are call-time; no `import/no-cycle` lint rule). 6 of those 9 gained an `export` keyword; the aliased `citationFromResult as resultCitation` import travels with the module; 5 imports orphaned by the move were removed from rag.ts. rag.ts 7,550 → 6,025 lines. Gate: tsc clean (56 pre-existing `fast-check` property-test errors only — `fast-check` is not installed in the shared node_modules; no new errors from the move), focused vitest 75/75, lint (only the 7 pre-existing rag.ts unused-import warnings; module 0), prettier clean. - **Trap logged:** a backgrounded `tsc … | tail; echo EXIT` reports the pipeline's (echo's) exit, not tsc's — always read the captured EXIT marker, never trust the background "exit 0" notification. This masked both the true baseline (56 fast-check errors) and move 1's first-cut breakage (9 unexported functions + a missed aliased import) until vitest failed. - **Move 2 (shipped): retrieval variants H → `rag-retrieval-variants.ts`.** Lines 1999–2283 moved verbatim (empty block diff modulo the `export` keyword added to 5 declarations): `buildRetrievalQueryVariants`, alias normalize/expand + live `fetchEnabledRagAliases`, `assertGlobalSearchAllowed`/`ownerScopeForDocumentFilteredRetrieval`, `shouldRelaxWeakTextMatches`, `textCandidateBudgetForQueryClass`, budget consts. rag.ts imports the 11 names it calls, re-exports the 6 `retrieval-query-variants.test.ts` imports. Back-edges: `SearchChunksArgs` (type-only, no runtime cycle) + `shouldShortCircuitUnsupportedSearch` (one call-time value fn, gained an `export`; belongs to the soft-tail family staying in rag.ts). 4 orphaned imports removed from rag.ts. rag.ts 6,025 → 5,750 lines. Gate: tsc clean, focused vitest 102/102 (incl. `retrieval-query-variants.test.ts`), lint (7 pre-existing warnings only), prettier clean. -- **Golden retrieval eval environment-blocked (both HEAD and pristine `origin/main`):** `npm run eval:retrieval:quality` currently FAILS ~36/36 with `text_candidates:0` on every case and `OpenAI quota is exhausted`. This is **not** a code regression: swapping in pristine `origin/main` `rag.ts` (moved modules inert) reproduces the identical failure pattern (15/15 lexical cases `text_candidates:0`, byte-for-byte). So the live Supabase text-RPC is returning 0 candidates and the OpenAI key's quota is spent — an infrastructure state, pre-existing and independent of this branch. Behavior preservation for moves 1–2 is therefore evidenced by byte-identical moves + tsc + focused vitest + **pristine-eval equivalence**; the mandated clean 36/36 must be run once the live keys/quota are restored (or in CI) before merge. +- **Move 3 (shipped, final): cache region F + context-packing J → `rag-cache.ts`.** Cache is interleaved in rag.ts with the query-classifier, unsupported-soft-tail, and query-expansion regions, so this was a **multi-region verbatim move** (4 contiguous regions assembled into one module, each byte-identical to source; diff shows only the `export` keyword on 11 boundary-crossing declarations): cache maps/consts + cache-key helpers + main cache block (get/set answer+search, shared-cache, `stableHash`, `retrievalPlanCacheQuery`, invalidation) + context-packing. rag.ts imports the 12 names it calls (incl. the `answerInflight` map) and re-exports 4 (`invalidateRagCachesForOwner`/`…ForDocumentMutation` for API routes; `packedContextCacheKey`/`retrievalPlanCacheQuery` for tests). Back-edges: `SearchChunksArgs` + `SearchTelemetry` (type-only) and one call-time value fn `retrievalPlanForQueryClass` (gained `export`). Notably the interleaved classifier/soft-tail/expansion helpers are cleanly _not_ referenced by the cache union — only those 3 back-edges. 3 orphaned imports removed. rag.ts 5,750 → 5,108 (**7,550 → 5,108 across part 2, −2,442 / −32%**; the file now holds the orchestrator + retrieval-diagnostics + classifier + coverage-gate + memory/enrichment + logging). Gate: tsc clean, focused vitest 118/118, lint (7 pre-existing warnings only; module 0), prettier clean. +- **Golden retrieval eval environment-blocked — root cause is a data/owner mismatch, not code or OpenAI:** `npm run eval:retrieval:quality` FAILS ~36/36 with every retrieval layer at 0 (`text_candidates:0`, `hybrid_vector:0`, …). Proven not a regression: pristine `origin/main` rag.ts (moved modules inert) reproduces the **byte-identical** failure output (24/24 printed cases, latency aside). Direct Supabase probe with the configured service key: **2,065 documents / 69,334 chunks present, but `owner_id` is `null` (global) on the sampled docs while `RAG_EVAL_OWNER_ID` (2bac05f1-…) owns 0**, and owner-scoped retrieval now **fail-closes** on a null owner (privacy hardening) — so no owner value retrieves the null-owner corpus and the eval returns 0 candidates. Restoring OpenAI credits fixed embeddings (`embedding:1` succeeds) but not this. To run a real 36/36, the corpus docs must be assigned to the eval owner (or the eval must target the actual owner / allow global search). Behavior preservation for moves 1–3 is evidenced by byte-identical moves + tsc + focused vitest + **pristine-eval equivalence**; the mandated clean 36/36 + `eval:quality --rag-only` must run once the eval-owner/corpus config is fixed (or in CI), before merge. ## Answer-thread Back button: URL and visible answer can disagree (2026-07-06)