Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions docs/codebase-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,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

Expand Down
10 changes: 10 additions & 0 deletions docs/maturity-backlog-workorders.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion scripts/check-maintainability-budgets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
]);
Expand Down
261 changes: 261 additions & 0 deletions src/lib/rag/rag-coverage-gate.ts
Original file line number Diff line number Diff line change
@@ -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<typeof evaluateEvidenceCoverageGate>,
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}`;
}
}
11 changes: 11 additions & 0 deletions src/lib/rag/rag-evidence-gates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading