From cdbcad639174bc2f3cc251681fd662f0b588f1e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 13:15:11 +0000 Subject: [PATCH] refactor(rag): extract evidence coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the evidence coverage gate out of the rag.ts monolith into a dedicated module. `evaluateEvidenceCoverageGate` and the `applyCoverageGateTelemetry` helper it owns move byte-for-byte; only the `export` keyword was added where a declaration now crosses the module boundary. The shared `visualEvidenceUnitTypes` taxonomy moves to `rag-evidence-gates.ts` because rag.ts's second-stage rerank still reads it — that keeps the new module free of any back-edge to rag.ts. `rag.ts` re-exports `evaluateEvidenceCoverageGate` so the public `@/lib/rag/rag` API is unchanged. `prepareCoverageGateResults` deliberately stays in rag.ts: it is pipeline orchestration calling the metadata/visual hydration and second-stage rerank helpers, so moving it would require a runtime back-edge. The hydration cluster is a separate later extraction. rag.ts 5030 -> 4780 lines; maintainability budget ratcheted 5030 -> 4780. RAG impact: no retrieval behaviour change — pure module extraction Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GGEBHp4Seoh1jK1vGTNtYS --- docs/codebase-index.md | 21 +- docs/maturity-backlog-workorders.md | 10 + scripts/check-maintainability-budgets.mjs | 4 +- src/lib/rag/rag-coverage-gate.ts | 261 ++++++++++++++++++++++ src/lib/rag/rag-evidence-gates.ts | 11 + src/lib/rag/rag.ts | 256 +-------------------- 6 files changed, 299 insertions(+), 264 deletions(-) create mode 100644 src/lib/rag/rag-coverage-gate.ts diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 0af4904fe6..8c6b153d2b 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -103,16 +103,17 @@ The `rag.ts` orchestrator and its `rag-*` cluster live in **`src/lib/rag/`** (th domain-extracted directory; imported as `@/lib/rag/rag*`). Other modules below remain flat in `src/lib/`. -| Module | Role | -| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `rag.ts` | Main answer pipeline orchestrator | -| `rag-routing.ts`, `rag-provider.ts`, `rag-answer-text.ts`, `smart-rag-api.ts` | Model routing, provider modes, API surface | -| `rag-contracts.ts`, `rag-answer-support.ts`, `rag-query-guard.ts` | Shared RAG contracts and pure answer/query policy | -| `rag-cache.ts`, `rag-retrieval-variants.ts` | Bounded caches and retrieval variants | -| `clinical-search.ts`, `clinical-query-mode.ts`, `retrieval-selection.ts` | Query modes and retrieval selection | -| `answer-ranking.ts`, `answer-verification.ts`, `answer-formatting.ts`, `answer-follow-up.ts`, `answer-render-policy.ts` | Answer quality and rendering | -| `citations.ts`, `cross-document-synthesis.ts`, `evidence-relevance.ts` | Evidence and synthesis | -| `ranking-config.ts`, `search-scope.ts`, `rag-eval-cases.ts` | Ranking tuning and eval fixtures | +| Module | Role | +| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `rag.ts` | Main answer pipeline orchestrator | +| `rag-routing.ts`, `rag-provider.ts`, `rag-answer-text.ts`, `smart-rag-api.ts` | Model routing, provider modes, API surface | +| `rag-contracts.ts`, `rag-answer-support.ts`, `rag-query-guard.ts` | Shared RAG contracts and pure answer/query policy | +| `rag-evidence-gates.ts`, `rag-coverage-gate.ts` | Evidence-sufficiency predicates and the fast-path evidence coverage gate | +| `rag-cache.ts`, `rag-retrieval-variants.ts` | Bounded caches and retrieval variants | +| `clinical-search.ts`, `clinical-query-mode.ts`, `retrieval-selection.ts` | Query modes and retrieval selection | +| `answer-ranking.ts`, `answer-verification.ts`, `answer-formatting.ts`, `answer-follow-up.ts`, `answer-render-policy.ts` | Answer quality and rendering | +| `citations.ts`, `cross-document-synthesis.ts`, `evidence-relevance.ts` | Evidence and synthesis | +| `ranking-config.ts`, `search-scope.ts`, `rag-eval-cases.ts` | Ranking tuning and eval fixtures | ### Ingestion and indexing diff --git a/docs/maturity-backlog-workorders.md b/docs/maturity-backlog-workorders.md index 0d077cc03f..05463d089d 100644 --- a/docs/maturity-backlog-workorders.md +++ b/docs/maturity-backlog-workorders.md @@ -82,6 +82,16 @@ structural change, not a single mixed PR. - **Progress (#997):** extracted the evidence-gate predicates from `rag.ts` into `src/lib/rag/rag-evidence-gates.ts` (rag.ts 5,147 → 5,018), pure moves behind the existing budgets. +- **Progress (#086):** extracted the evidence coverage gate from `rag.ts` into + `src/lib/rag/rag-coverage-gate.ts` — `evaluateEvidenceCoverageGate` plus the + `applyCoverageGateTelemetry` helper it owns, moved byte-for-byte (rag.ts 5,030 → 4,780, + budget ratcheted to 4,780). The shared `visualEvidenceUnitTypes` taxonomy moved to + `rag-evidence-gates.ts` because rag.ts's second-stage rerank still reads it; that keeps the + new module cycle-free, and `rag.ts` re-exports `evaluateEvidenceCoverageGate` so the public + `@/lib/rag/rag` API is unchanged. `prepareCoverageGateResults` deliberately stayed in + `rag.ts`: it is pipeline orchestration that calls the metadata/visual hydration and + second-stage rerank helpers, so moving it would need a runtime back-edge to `rag.ts`. The + hydration cluster is the separate later extraction (`rag-hydration.ts`). - **Progress (`DocumentViewer.tsx`):** extracted the cohesive leaf modules into `src/components/document-viewer/` — shared row `types.ts`, `source-panels.tsx` (summary profile, high-yield summary, source images/tables, pinned evidence, indexed-text panel), the diff --git a/scripts/check-maintainability-budgets.mjs b/scripts/check-maintainability-budgets.mjs index ab3076b92c..3d190cf457 100644 --- a/scripts/check-maintainability-budgets.mjs +++ b/scripts/check-maintainability-budgets.mjs @@ -5,7 +5,9 @@ const budgets = new Map([ // Chrome ownership/reporting lives in use-dashboard-chrome-coordinator; keep // the reclaimed monolith budget so it cannot silently drift back to 4160. ["src/components/ClinicalDashboard.tsx", 4140], - ["src/lib/rag/rag.ts", 5030], + // The evidence coverage gate lives in rag-coverage-gate; keep the reclaimed + // budget so it cannot silently drift back to 5030. + ["src/lib/rag/rag.ts", 4780], ["src/components/DocumentViewer.tsx", 1734], ["supabase/functions/indexing-v3-agent/index.ts", 2191], ]); diff --git a/src/lib/rag/rag-coverage-gate.ts b/src/lib/rag/rag-coverage-gate.ts new file mode 100644 index 0000000000..9b8192d26d --- /dev/null +++ b/src/lib/rag/rag-coverage-gate.ts @@ -0,0 +1,261 @@ +import type { RagQueryClass, SearchResult } from "@/lib/types"; +import { + classifyRagQuery, + hasDoseEvidenceSupport, + hasStructuredThresholdEvidence, + medicationDoseEvidenceQueryIntent, + medicationDoseQueryContext, +} from "@/lib/clinical-search"; +import type { SearchTelemetry } from "@/lib/rag/rag-contracts"; +import { + directTitleOrAliasSupport, + hasAdmissionCommunityLookupIntent, + hasAdmissionCommunityTitleSupport, + hasAnyTerm, + hasDirectSourceImageEvidence, + hasDoseAmountEvidenceForGate, + hasFrequencyEvidenceForGate, + hasRiskFlowchartActionEvidence, + hasRouteEvidenceForGate, + isRiskFlowchartNextStepQuery, + sourceImageRequiredForQuery, + topEvidenceText, + visualEvidenceUnitTypes, +} from "@/lib/rag/rag-evidence-gates"; + +// Extracted from rag.ts (maturity X3): the evidence coverage gate that decides +// whether retrieved candidates are sufficient to release a fast-path answer, +// plus the telemetry it owns. Behaviour-preserving — the function bodies are +// byte-identical to their previous rag.ts definitions. + +/** Evaluate evidence coverage gate. */ +export function evaluateEvidenceCoverageGate( + query: string, + results: SearchResult[], + queryClass: RagQueryClass = classifyRagQuery(query).queryClass, +): { + accepted: boolean; + reason: string; + strategy: "text_fast_path" | "document_lookup_fast_path"; + sourceImageRequired: boolean; + sourceImageSatisfied: boolean; +} { + if (!results.length) { + return { + accepted: false, + reason: "no_candidates", + strategy: "text_fast_path", + sourceImageRequired: false, + sourceImageSatisfied: false, + }; + } + + const top = results.slice(0, 5); + const evidenceText = topEvidenceText(results); + const strongestScore = Math.max(0, ...top.map((result) => result.hybrid_score ?? result.similarity ?? 0)); + const sourceImageRequired = sourceImageRequiredForQuery(query); + const sourceImageSatisfied = top.some(hasDirectSourceImageEvidence); + if (sourceImageRequired && !sourceImageSatisfied) { + return { + accepted: false, + reason: "source_image_required_missing", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + + const hasStructuredThreshold = top.some(hasStructuredThresholdEvidence); + const hasDoseAmount = top.some(hasDoseAmountEvidenceForGate); + const hasVisualUnit = top.some((result) => visualEvidenceUnitTypes.has(result.index_unit?.unit_type ?? "")); + const hasDirectTitle = directTitleOrAliasSupport(query, top); + + if (queryClass === "table_threshold") { + if ( + /\bclozapine\b/i.test(query) && + /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i.test(query) && + /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped)\b/i.test(query) + ) { + const hasBlood = hasAnyTerm( + evidenceText, + /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i, + ); + const hasAction = hasAnyTerm( + evidenceText, + /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped|red)\b/i, + ); + return { + accepted: hasStructuredThreshold && hasBlood && hasAction, + reason: + hasStructuredThreshold && hasBlood && hasAction + ? "clozapine_blood_action_structured_threshold" + : "missing_clozapine_blood_action_structured_threshold", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + if (/\bpatient property\b/i.test(query)) { + const hasPropertyTerms = + hasAnyTerm(evidenceText, /\bpatient\b/i) && + hasAnyTerm(evidenceText, /\bproperty\b/i) && + hasAnyTerm(evidenceText, /\b(?:restricted|prohibited|contraband|items?)\b/i); + return { + accepted: + hasPropertyTerms && + (hasStructuredThreshold || sourceImageSatisfied || hasVisualUnit || strongestScore >= 0.62), + reason: hasPropertyTerms ? "patient_property_restricted_items_gate" : "missing_patient_property_terms", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + return { + accepted: hasStructuredThreshold && strongestScore >= 0.58, + reason: hasStructuredThreshold ? "structured_threshold_evidence_gate" : "missing_structured_threshold_evidence", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + + if (queryClass === "medication_dose_risk") { + const { asksAmount, asksRoute, asksFrequency } = medicationDoseEvidenceQueryIntent(query); + const agitationOk = !/\bagitation|arousal\b/i.test(query) || /\bagitation|arousal\b/i.test(evidenceText); + const hasContextualDoseEvidence = top.some( + (result) => hasDoseEvidenceSupport(result) && medicationDoseQueryContext(query, result).matched, + ); + const hasContextualDoseAmount = top.some( + (result) => + hasDoseEvidenceSupport(result) && + hasDoseAmountEvidenceForGate(result) && + medicationDoseQueryContext(query, result).matched, + ); + const hasContextualRoute = top.some( + (result) => + hasDoseEvidenceSupport(result) && + hasRouteEvidenceForGate(result) && + medicationDoseQueryContext(query, result).matched, + ); + const hasContextualFrequency = top.some( + (result) => + hasDoseEvidenceSupport(result) && + hasFrequencyEvidenceForGate(result) && + medicationDoseQueryContext(query, result).matched, + ); + const hasCoLocatedRequestedEvidence = top.some( + (result) => + hasDoseEvidenceSupport(result) && + medicationDoseQueryContext(query, result).matched && + (!asksAmount || hasDoseAmountEvidenceForGate(result)) && + (!asksRoute || hasRouteEvidenceForGate(result)) && + (!asksFrequency || hasFrequencyEvidenceForGate(result)), + ); + const requestedAttributeCount = Number(asksAmount) + Number(asksRoute) + Number(asksFrequency); + const accepted = hasCoLocatedRequestedEvidence && agitationOk; + return { + accepted, + reason: accepted + ? "dose_route_amount_evidence_gate" + : asksAmount && !hasDoseAmount + ? "missing_dose_amount_evidence" + : !hasContextualDoseEvidence || (asksAmount && !hasContextualDoseAmount) + ? "missing_dose_query_context" + : !hasContextualRoute && asksRoute + ? "missing_route_evidence" + : !hasContextualFrequency && asksFrequency + ? "missing_frequency_evidence" + : requestedAttributeCount > 1 && !hasCoLocatedRequestedEvidence + ? "missing_co_located_medication_evidence" + : !agitationOk + ? "missing_agitation_context" + : "missing_dose_evidence", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + + if (queryClass === "document_lookup") { + if (hasAdmissionCommunityLookupIntent(query) && !hasAdmissionCommunityTitleSupport(top)) { + return { + accepted: false, + reason: "missing_admission_community_title_support", + strategy: "document_lookup_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + if (/\bactive community patients?\b/i.test(query) && /\bed\b/i.test(query)) { + const accepted = + hasDirectTitle && + hasAnyTerm(evidenceText, /\bactive\b/i) && + hasAnyTerm(evidenceText, /\bcommunity\b/i) && + hasAnyTerm(evidenceText, /\b(?:ed|emergency department)\b/i); + return { + accepted, + reason: accepted ? "active_community_ed_title_gate" : "missing_active_community_ed_title_support", + strategy: "document_lookup_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + // Only zone/next-step flowchart questions need the zone-action evidence + // gate; a plain flowchart document lookup ("which procedure flowchart + // covers X?") falls through to the ordinary title gate below so a direct + // title hit is not rejected for lacking zone evidence. + if (isRiskFlowchartNextStepQuery(query)) { + const accepted = hasRiskFlowchartActionEvidence(query, results); + return { + accepted, + reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", + strategy: "document_lookup_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + return { + accepted: hasDirectTitle && strongestScore >= 0.48, + reason: hasDirectTitle ? "document_title_evidence_gate" : "missing_document_title_support", + strategy: "document_lookup_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + + if (queryClass === "comparison") { + const distinctDocuments = new Set(top.map((result) => result.document_id)).size; + return { + accepted: distinctDocuments >= 2 && strongestScore >= 0.6, + reason: distinctDocuments >= 2 ? "comparison_multi_document_gate" : "missing_comparison_document_diversity", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; + } + + return { + accepted: false, + reason: "coverage_gate_not_applicable", + strategy: "text_fast_path", + sourceImageRequired, + sourceImageSatisfied, + }; +} + +/** Apply coverage gate telemetry. */ +export function applyCoverageGateTelemetry( + telemetry: SearchTelemetry, + gate: ReturnType, + accepted: boolean, +) { + telemetry.coverage_gate_decision = accepted ? "accepted" : "rejected"; + telemetry.coverage_gate_reason = gate.reason; + telemetry.source_image_required = gate.sourceImageRequired; + telemetry.source_image_satisfied = gate.sourceImageSatisfied; + if (accepted) { + telemetry.vector_skipped_reason = `evidence_coverage_gate:${gate.reason}`; + telemetry.embedding_skipped = true; + telemetry.embedding_skip_reason = `evidence_coverage_gate:${gate.reason}`; + } +} diff --git a/src/lib/rag/rag-evidence-gates.ts b/src/lib/rag/rag-evidence-gates.ts index 2e0258bdf9..dc0332c7aa 100644 --- a/src/lib/rag/rag-evidence-gates.ts +++ b/src/lib/rag/rag-evidence-gates.ts @@ -13,6 +13,17 @@ import { hasDirectTitleSupport } from "@/lib/rag/rag-routing"; // decide fast-path eligibility and evidence sufficiency. Behaviour-preserving — // the function bodies are byte-identical to their previous rag.ts definitions. +export const visualEvidenceUnitTypes = new Set([ + "visual_summary", + "visual_askable_question", + "table_threshold", + "medication_chart_row", + "flowchart_step", + "diagram_decision", + "risk_matrix_cell", + "chart_finding", +]); + /** Normalize document alias text. */ function normalizeDocumentAliasText(value: string) { return value diff --git a/src/lib/rag/rag.ts b/src/lib/rag/rag.ts index 0cf4833381..276b66199a 100644 --- a/src/lib/rag/rag.ts +++ b/src/lib/rag/rag.ts @@ -144,8 +144,6 @@ import { hasDoseEvidenceSupport, hasStructuredThresholdEvidence, isMedicationDoseEvidenceQuery, - medicationDoseEvidenceQueryIntent, - medicationDoseQueryContext, normalizedClinicalSearchTokens, rankClinicalResults, } from "@/lib/clinical-search"; @@ -211,20 +209,15 @@ import { } from "@/lib/rag/rag-query-guard"; export { shouldShortCircuitUnsupportedSearch } from "@/lib/rag/rag-query-guard"; import { - directTitleOrAliasSupport, hasAdmissionCommunityLookupIntent, hasAdmissionCommunityTitleSupport, - hasAnyTerm, - hasDirectSourceImageEvidence, hasDocumentAliasWithoutTopTitleSupport, - hasDoseAmountEvidenceForGate, - hasFrequencyEvidenceForGate, hasRiskFlowchartActionEvidence, - hasRouteEvidenceForGate, isRiskFlowchartNextStepQuery, - sourceImageRequiredForQuery, - topEvidenceText, + visualEvidenceUnitTypes, } from "@/lib/rag/rag-evidence-gates"; +import { applyCoverageGateTelemetry, evaluateEvidenceCoverageGate } from "@/lib/rag/rag-coverage-gate"; +export { evaluateEvidenceCoverageGate } from "@/lib/rag/rag-coverage-gate"; import { cleanClinicalSummaryText, isLowYieldClinicalText } from "@/lib/source-text-sanitizer"; import { hasClinicalAnswerQualityIssue, @@ -511,17 +504,6 @@ type AnswerQuestionWithScopeArgs = SearchChunksArgs & { signal?: AbortSignal; }; -const visualEvidenceUnitTypes = new Set([ - "visual_summary", - "visual_askable_question", - "table_threshold", - "medication_chart_row", - "flowchart_step", - "diagram_decision", - "risk_matrix_cell", - "chart_finding", -]); - const tableVisualEvidenceUnitTypes = new Set([ "table_fact", "table_threshold", @@ -1861,221 +1843,6 @@ function selectRankedRetrievalResults(args: { return selection.results; } -/** Evaluate evidence coverage gate. */ -export function evaluateEvidenceCoverageGate( - query: string, - results: SearchResult[], - queryClass: RagQueryClass = classifyRagQuery(query).queryClass, -): { - accepted: boolean; - reason: string; - strategy: "text_fast_path" | "document_lookup_fast_path"; - sourceImageRequired: boolean; - sourceImageSatisfied: boolean; -} { - if (!results.length) { - return { - accepted: false, - reason: "no_candidates", - strategy: "text_fast_path", - sourceImageRequired: false, - sourceImageSatisfied: false, - }; - } - - const top = results.slice(0, 5); - const evidenceText = topEvidenceText(results); - const strongestScore = Math.max(0, ...top.map((result) => result.hybrid_score ?? result.similarity ?? 0)); - const sourceImageRequired = sourceImageRequiredForQuery(query); - const sourceImageSatisfied = top.some(hasDirectSourceImageEvidence); - if (sourceImageRequired && !sourceImageSatisfied) { - return { - accepted: false, - reason: "source_image_required_missing", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - - const hasStructuredThreshold = top.some(hasStructuredThresholdEvidence); - const hasDoseAmount = top.some(hasDoseAmountEvidenceForGate); - const hasVisualUnit = top.some((result) => visualEvidenceUnitTypes.has(result.index_unit?.unit_type ?? "")); - const hasDirectTitle = directTitleOrAliasSupport(query, top); - - if (queryClass === "table_threshold") { - if ( - /\bclozapine\b/i.test(query) && - /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i.test(query) && - /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped)\b/i.test(query) - ) { - const hasBlood = hasAnyTerm( - evidenceText, - /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i, - ); - const hasAction = hasAnyTerm( - evidenceText, - /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped|red)\b/i, - ); - return { - accepted: hasStructuredThreshold && hasBlood && hasAction, - reason: - hasStructuredThreshold && hasBlood && hasAction - ? "clozapine_blood_action_structured_threshold" - : "missing_clozapine_blood_action_structured_threshold", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - if (/\bpatient property\b/i.test(query)) { - const hasPropertyTerms = - hasAnyTerm(evidenceText, /\bpatient\b/i) && - hasAnyTerm(evidenceText, /\bproperty\b/i) && - hasAnyTerm(evidenceText, /\b(?:restricted|prohibited|contraband|items?)\b/i); - return { - accepted: - hasPropertyTerms && - (hasStructuredThreshold || sourceImageSatisfied || hasVisualUnit || strongestScore >= 0.62), - reason: hasPropertyTerms ? "patient_property_restricted_items_gate" : "missing_patient_property_terms", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - return { - accepted: hasStructuredThreshold && strongestScore >= 0.58, - reason: hasStructuredThreshold ? "structured_threshold_evidence_gate" : "missing_structured_threshold_evidence", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - - if (queryClass === "medication_dose_risk") { - const { asksAmount, asksRoute, asksFrequency } = medicationDoseEvidenceQueryIntent(query); - const agitationOk = !/\bagitation|arousal\b/i.test(query) || /\bagitation|arousal\b/i.test(evidenceText); - const hasContextualDoseEvidence = top.some( - (result) => hasDoseEvidenceSupport(result) && medicationDoseQueryContext(query, result).matched, - ); - const hasContextualDoseAmount = top.some( - (result) => - hasDoseEvidenceSupport(result) && - hasDoseAmountEvidenceForGate(result) && - medicationDoseQueryContext(query, result).matched, - ); - const hasContextualRoute = top.some( - (result) => - hasDoseEvidenceSupport(result) && - hasRouteEvidenceForGate(result) && - medicationDoseQueryContext(query, result).matched, - ); - const hasContextualFrequency = top.some( - (result) => - hasDoseEvidenceSupport(result) && - hasFrequencyEvidenceForGate(result) && - medicationDoseQueryContext(query, result).matched, - ); - const hasCoLocatedRequestedEvidence = top.some( - (result) => - hasDoseEvidenceSupport(result) && - medicationDoseQueryContext(query, result).matched && - (!asksAmount || hasDoseAmountEvidenceForGate(result)) && - (!asksRoute || hasRouteEvidenceForGate(result)) && - (!asksFrequency || hasFrequencyEvidenceForGate(result)), - ); - const requestedAttributeCount = Number(asksAmount) + Number(asksRoute) + Number(asksFrequency); - const accepted = hasCoLocatedRequestedEvidence && agitationOk; - return { - accepted, - reason: accepted - ? "dose_route_amount_evidence_gate" - : asksAmount && !hasDoseAmount - ? "missing_dose_amount_evidence" - : !hasContextualDoseEvidence || (asksAmount && !hasContextualDoseAmount) - ? "missing_dose_query_context" - : !hasContextualRoute && asksRoute - ? "missing_route_evidence" - : !hasContextualFrequency && asksFrequency - ? "missing_frequency_evidence" - : requestedAttributeCount > 1 && !hasCoLocatedRequestedEvidence - ? "missing_co_located_medication_evidence" - : !agitationOk - ? "missing_agitation_context" - : "missing_dose_evidence", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - - if (queryClass === "document_lookup") { - if (hasAdmissionCommunityLookupIntent(query) && !hasAdmissionCommunityTitleSupport(top)) { - return { - accepted: false, - reason: "missing_admission_community_title_support", - strategy: "document_lookup_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - if (/\bactive community patients?\b/i.test(query) && /\bed\b/i.test(query)) { - const accepted = - hasDirectTitle && - hasAnyTerm(evidenceText, /\bactive\b/i) && - hasAnyTerm(evidenceText, /\bcommunity\b/i) && - hasAnyTerm(evidenceText, /\b(?:ed|emergency department)\b/i); - return { - accepted, - reason: accepted ? "active_community_ed_title_gate" : "missing_active_community_ed_title_support", - strategy: "document_lookup_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - // Only zone/next-step flowchart questions need the zone-action evidence - // gate; a plain flowchart document lookup ("which procedure flowchart - // covers X?") falls through to the ordinary title gate below so a direct - // title hit is not rejected for lacking zone evidence. - if (isRiskFlowchartNextStepQuery(query)) { - const accepted = hasRiskFlowchartActionEvidence(query, results); - return { - accepted, - reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", - strategy: "document_lookup_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - return { - accepted: hasDirectTitle && strongestScore >= 0.48, - reason: hasDirectTitle ? "document_title_evidence_gate" : "missing_document_title_support", - strategy: "document_lookup_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - - if (queryClass === "comparison") { - const distinctDocuments = new Set(top.map((result) => result.document_id)).size; - return { - accepted: distinctDocuments >= 2 && strongestScore >= 0.6, - reason: distinctDocuments >= 2 ? "comparison_multi_document_gate" : "missing_comparison_document_diversity", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; - } - - return { - accepted: false, - reason: "coverage_gate_not_applicable", - strategy: "text_fast_path", - sourceImageRequired, - sourceImageSatisfied, - }; -} - /** Prepare coverage gate results. */ async function prepareCoverageGateResults(args: { supabase: ReturnType; @@ -2116,23 +1883,6 @@ async function prepareCoverageGateResults(args: { return results; } -/** Apply coverage gate telemetry. */ -function applyCoverageGateTelemetry( - telemetry: SearchTelemetry, - gate: ReturnType, - accepted: boolean, -) { - telemetry.coverage_gate_decision = accepted ? "accepted" : "rejected"; - telemetry.coverage_gate_reason = gate.reason; - telemetry.source_image_required = gate.sourceImageRequired; - telemetry.source_image_satisfied = gate.sourceImageSatisfied; - if (accepted) { - telemetry.vector_skipped_reason = `evidence_coverage_gate:${gate.reason}`; - telemetry.embedding_skipped = true; - telemetry.embedding_skip_reason = `evidence_coverage_gate:${gate.reason}`; - } -} - /** Mark embedding skipped by text fast path. */ function markEmbeddingSkippedByTextFastPath(telemetry: SearchTelemetry, reason: string | null) { telemetry.embedding_skipped = true;