diff --git a/.github/workflows/live-drift.yml b/.github/workflows/live-drift.yml index 2345b14be..29ad44b8b 100644 --- a/.github/workflows/live-drift.yml +++ b/.github/workflows/live-drift.yml @@ -20,7 +20,6 @@ env: SUPABASE_PROJECT_REF: sjrfecxgysukkwxsowpy SUPABASE_PROJECT_NAME: Clinical KB Database NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: placeholder-ci-anon-key - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} jobs: live-drift: @@ -34,6 +33,8 @@ jobs: persist-credentials: false - name: Preflight required secrets + env: + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} run: | missing="" [ -z "$SUPABASE_SERVICE_ROLE_KEY" ] && missing="$missing SUPABASE_SERVICE_ROLE_KEY" @@ -54,6 +55,10 @@ jobs: - name: Guard Supabase project identity run: npm run check:supabase-project + env: + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} - name: Compare live schema drift run: npm run check:drift + env: + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 9226ec5f6..bd05d445a 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,6 +18,9 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | -------------- | --------------------------------------- | -------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| 2026-07-09 | example/branch | abc1234567890abcdef1234567890abcdef1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | -------------------------- | ---------------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-09 | example/branch | abc1234567890abcdef1234567890abcdef1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-09 | claude/llm-pipeline-review | 009e85c6be437d98dd868d26cc797327bf8fc377 | design-review | Passed; resolved 3 minor P3 design system alignment issues (fixed arbitrary font sizes to `text-2xs` and `min-h-11` to `min-h-tap`). | `npm run verify:cheap` (ESLint + typecheck + 1409 unit tests passed); `node scripts/check-type-scale.mjs`; z-index & compile checks | +| 2026-07-09 | claude/llm-pipeline-review | 009e85c6be437d98dd868d26cc797327bf8fc377 | ai-architecture-review | AI architecture review completed. Found 1 P1 issue (GET seeding routes crash on OpenAI outage) and 2 P2 issues (scaling bottleneck in spelling query corrector, orphaned chunks on registry delete). All 1409 tests passed. | `git diff origin/main...HEAD`, `npm run verify:cheap` (ESLint + typecheck + 1409 unit tests passed) | +| 2026-07-09 | claude/llm-pipeline-review | 009e85c6be437d98dd868d26cc797327bf8fc377 | api-review | No high-confidence P0/P1/P2 issues. Identified minor validation sequence and parameter bounds checks (P3). | `npm run verify:cheap` (TypeScript typecheck, ESLint, and isolated Vitest runs on medications-route, rag-answer-fallback, and rag-cache-invalidation) | diff --git a/docs/rag-hybrid-findings-and-todo.md b/docs/rag-hybrid-findings-and-todo.md index ef4558a80..001d99584 100644 --- a/docs/rag-hybrid-findings-and-todo.md +++ b/docs/rag-hybrid-findings-and-todo.md @@ -313,8 +313,9 @@ denied to set parameter`)** — the RC11 blocker. The only method hosted allows (`00000000-0000-0000-0000-000000000000`) correctly found zero rows. `--list-owners` found the real registry owner `4f1b3c19-3c39-4597-b9df-168c8e6007ff` with 739 eligible rows; guarded write with `RAG_REGISTRY_CORPUS_EMBEDDING=true --write --confirm` upserted 739 synthetic registry corpus - chunks. Post-write retrieval eval passed with `document_recall_at_5=1`, `content_recall_at_5=1`, - `top_k_hit_rate=1`, `force_embedding_failure_count=0`, `failed_cases=[]`. Post-write + chunks. Ran `npm run eval:retrieval:quality -- --force-embedding`: passed with + `document_recall_at_5=1`, `content_recall_at_5=1`, `top_k_hit_rate=1`, + `force_embedding_failure_count=0`, `failed_cases=[]`. Post-write `eval:quality -- --rag-only` completed under budget; invented-term controls still refused and numeric grounding failure rate was `0`. Remaining blockers are not registry regressions: citation failure rate `0.0227` and RAG latency thresholds (`p95=44847ms`; route p95 extractive diff --git a/scripts/check-m13-migration.ts b/scripts/check-m13-migration.ts index 741e3ea2b..3ec4b6e20 100644 --- a/scripts/check-m13-migration.ts +++ b/scripts/check-m13-migration.ts @@ -1,7 +1,5 @@ import { loadEnvConfig } from "@next/env"; -import { createAdminClient } from "@/lib/supabase/admin"; - loadEnvConfig(process.cwd()); const M13_HEALTH_MARKER = "commit_document_index_generation.preserve_legacy_artifacts_migration"; @@ -19,6 +17,7 @@ function missingMarkers(data: unknown) { } async function main() { + const { createAdminClient } = await import("@/lib/supabase/admin"); const supabase = createAdminClient(); const { data, error } = await supabase.rpc("search_schema_health"); if (error) { diff --git a/scripts/embed-registry-records.ts b/scripts/embed-registry-records.ts index 1e9b282e6..122738f18 100644 --- a/scripts/embed-registry-records.ts +++ b/scripts/embed-registry-records.ts @@ -18,6 +18,7 @@ type Args = { listOwners: boolean; }; +/** Parse args. */ function parseArgs(argv: string[]): Args { const args: Args = { ownerId: process.env.LOCAL_NO_AUTH_OWNER_ID, @@ -60,17 +61,20 @@ function parseArgs(argv: string[]): Args { return args; } +/** Load admin client. */ async function loadAdminClient() { const { createAdminClient } = await import("@/lib/supabase/admin"); return createAdminClient(); } +/** Registry kinds. */ function registryKinds(kind: EmbedKind): RegistryRecordKind[] { if (kind === "all") return ["service", "form"]; if (kind === "service" || kind === "form") return [kind]; return []; } +/** Load registry rows. */ async function loadRegistryRows(supabase: Awaited>, args: Args, ownerId: string) { const kinds = registryKinds(args.kind); if (kinds.length === 0) return [] as RegistryRecordRow[]; @@ -81,6 +85,7 @@ async function loadRegistryRows(supabase: Awaited>, args: Args, ownerId: string) { if (args.kind !== "all" && args.kind !== "medication") return [] as MedicationRecordRow[]; let query = supabase.from("medication_records").select("*").eq("owner_id", ownerId); @@ -90,6 +95,7 @@ async function loadMedicationRows(supabase: Awaited>, args: Args, @@ -111,6 +117,7 @@ type OwnerCounts = { differential: number; }; +/** Ensure owner count. */ function ensureOwnerCount(counts: Map, ownerId: string) { let count = counts.get(ownerId); if (!count) { @@ -120,6 +127,7 @@ function ensureOwnerCount(counts: Map, ownerId: string) { return count; } +/** List eligible owner counts. */ async function listEligibleOwnerCounts(supabase: Awaited>) { const counts = new Map(); @@ -187,6 +195,7 @@ async function listEligibleOwnerCounts(supabase: Awaited - + Open source @@ -53,7 +53,7 @@ export function SourceActionRow({ Add scope {imageCount > 0 && ( - + {imageCount} indexed image{imageCount === 1 ? "" : "s"} )} @@ -124,7 +124,7 @@ export function SourcePassageLinks({ href={sourceResultHref(source)} className={cn( compact ? metadataPill : floatingControl, - "min-h-11 gap-1.5 px-2.5 text-2xs sm:min-h-9 sm:px-3", + "min-h-tap gap-1.5 px-2.5 text-2xs sm:min-h-9 sm:px-3", )} title={`${source.title} · page ${source.page_number ?? "n/a"} · chunk ${source.chunk_index}`} aria-label={`Open source passage #${index + 1}`} diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index e899fe11e..5c1b30adf 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -559,7 +559,7 @@ export function FormDetailPage({ form }: { form: FormRecord }) { diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 9fc8159da..802b5dde3 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -75,7 +75,7 @@ export const chatActionRow = export const chatMicroAction = "inline-flex min-h-tap min-w-tap items-center justify-center gap-1.5 rounded-md px-2 text-xs font-semibold text-[color:var(--text-muted)] transition hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50"; export const sourceCapsule = - "source-capsule-hover focus-ring-premium inline-flex min-h-tap items-center gap-1 rounded-md border border-[color:var(--border)] bg-[color-mix(in_srgb,var(--clinical-accent-soft)_55%,var(--surface))] px-2 text-[11px] font-medium text-[color:var(--clinical-accent)] transition hover:border-[color:var(--clinical-accent-border)] sm:px-2.5"; + "source-capsule-hover focus-ring-premium inline-flex min-h-tap items-center gap-1 rounded-md border border-[color:var(--border)] bg-[color-mix(in_srgb,var(--clinical-accent-soft)_55%,var(--surface))] px-2 text-2xs font-medium text-[color:var(--clinical-accent)] transition hover:border-[color:var(--clinical-accent-border)] sm:px-2.5"; export const sourceCapsuleCountBadge = "nums inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-[color:var(--surface-raised)] px-1.5 text-2xs font-semibold text-[color:var(--clinical-accent)] shadow-[var(--shadow-inset)]"; export const evidenceRow = diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 3f51cd6f2..fe17ce992 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -44,6 +44,7 @@ export const riskZoneActionPattern = /\b(?:escalat\w+|urgent\w*|respond\w*|actions?\s+required|call(?:ing)?\s+(?:a\s+)?met\b|met\s+call|(?:senior|immediate|medical|clinical|clinician|nursing|officer)\s+(?:clinician\s+|specialist\s+|nurse\s+|officer\s+)?review\w*)\b/i; // The zone colour a query names, if any ("red-zone risk" -> "red"). +/** Queried zone colour. */ export function queriedZoneColour(query: string) { return query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]?.toLowerCase() ?? null; } @@ -54,6 +55,7 @@ export function queriedZoneColour(query: string) { // bare token ("... | Red | escalate ..."). Shared by the fast-path guard and // coverage gate in rag.ts and the ranking source check here so a red-zone // question is never satisfied (or boosted) by another colour's action evidence. +/** Zone context patterns for query. */ export function zoneContextPatternsForQuery(query: string) { const colour = queriedZoneColour(query); const colourGroup = colour ?? `(?:${zoneColourAlternatives})`; @@ -366,6 +368,7 @@ const outsideCorpusMedicalPattern = const shortClinicalSearchTerms = new Set(["ed", "im", "po", "pt"]); const simpleRequirementsQuestionPattern = /^\s*(?:what|which)\s+(?:are|is)\s+.+\brequirements?\s*\??\s*$/i; +/** Tokens. */ function tokens(text: string) { return text .replace(/([a-z])([A-Z])/g, "$1 $2") @@ -374,6 +377,7 @@ function tokens(text: string) { .filter((token) => token.length > 2 || shortClinicalSearchTerms.has(token)); } +/** Normalize analysis text. */ function normalizeAnalysisText(text: string) { return text .normalize("NFKC") @@ -384,10 +388,12 @@ function normalizeAnalysisText(text: string) { .trim(); } +/** Corrected tokens. */ function correctedTokens(query: string) { return tokens(query).map((token) => typoCorrections.get(token) ?? token); } +/** Unique. */ function unique(values: string[], limit = 30) { const seen = new Set(); const output: string[] = []; @@ -401,6 +407,7 @@ function unique(values: string[], limit = 30) { return output; } +/** Term matches query. */ function termMatchesQuery(normalizedQuery: string, term: string) { const normalizedTerm = normalizeAnalysisText(term); if (!normalizedTerm) return false; @@ -408,6 +415,7 @@ function termMatchesQuery(normalizedQuery: string, term: string) { return new RegExp(`(?:^|\\s)${escaped}(?:$|\\s)`).test(normalizedQuery); } +/** Aliases for query. */ function aliasesForQuery(normalizedQuery: string, groups: string[][]) { const matches: string[] = []; for (const group of groups) { @@ -418,18 +426,21 @@ function aliasesForQuery(normalizedQuery: string, groups: string[][]) { return unique(matches, 40); } +/** Medication terms. */ function medicationTerms(normalizedQuery: string) { return aliasesForQuery(normalizedQuery, medicationAliasGroups).filter((term) => medicationAliasGroups.some((group) => group[0] === term), ); } +/** Acronym terms. */ function acronymTerms(query: string, normalizedQuery: string) { const direct = (query.match(/\b[A-Z]{2,6}\b/g) ?? []).map((term) => term.toLowerCase()); const aliases = aliasesForQuery(normalizedQuery, domainAliasGroups).filter((term) => /^[a-z]{2,6}$/.test(term)); return unique([...direct, ...aliases], 12); } +/** Threshold terms for. */ function thresholdTermsFor(normalizedQuery: string) { if (!thresholdSignalPattern.test(normalizedQuery)) return []; return unique( @@ -442,6 +453,7 @@ function thresholdTermsFor(normalizedQuery: string) { ); } +/** Build stable query rewrite. */ function buildStableQueryRewrite(args: { originalQuery: string; normalizedQuery: string; @@ -496,10 +508,12 @@ function buildStableQueryRewrite(args: { }; } +/** Document title terms for. */ function documentTitleTermsFor(normalizedQuery: string) { return aliasesForQuery(normalizedQuery, documentTitleAliasGroups); } +/** Query class from signals. */ function queryClassFromSignals(args: { normalizedQuery: string; medications: string[]; @@ -553,6 +567,7 @@ function queryClassFromSignals(args: { return "unsupported_or_general"; } +/** Intent from signals. */ function intentFromSignals(queryClass: RagQueryClass, normalizedQuery: string): ClinicalQueryIntent { if (queryClass === "comparison") return "comparison"; if (queryClass === "broad_summary") return "broad_summary"; @@ -574,6 +589,7 @@ function intentFromSignals(queryClass: RagQueryClass, normalizedQuery: string): return "general"; } +/** Analysis confidence. */ function analysisConfidence(args: { queryClass: RagQueryClass; reasons: string[]; @@ -590,6 +606,7 @@ function analysisConfidence(args: { return clamp(score); } +/** Parse date as years ago. */ function parseDateAsYearsAgo(value?: string | null) { if (!value) return null; const date = new Date(value); @@ -598,6 +615,7 @@ function parseDateAsYearsAgo(value?: string | null) { return Math.max(0, (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24 * 365)); } +/** Clamp. */ function clamp(value: number) { return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; } @@ -609,6 +627,7 @@ function clamp(value: number) { // a digit-adjacent allowance so unit-attached doses ("100mg") still count; // longer entries keep deliberate prefix semantics ("escalat" → escalation, // "table" → tables). +/** Contains any. */ function containsAny(value: string, values: readonly string[]) { return values.some((item) => { const escaped = item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -617,6 +636,7 @@ function containsAny(value: string, values: readonly string[]) { }); } +/** Normalize query token for lookups. */ function normalizeQueryTokenForLookups(value: string) { return value .toLowerCase() @@ -626,16 +646,19 @@ function normalizeQueryTokenForLookups(value: string) { .trim(); } +/** Normalized clinical query tokens. */ function normalizedClinicalQueryTokens(query: string) { return normalizedClinicalSearchTokens(query) .map((token) => token.toLowerCase()) .filter((token) => token.length > 2); } +/** Has image evidence need. */ function hasImageEvidenceNeed(query: string) { return /table|chart|diagram|flowchart|figure|image|visual|source image|dose card|medication chart/i.test(query); } +/** Extraction quality score. */ function extractionQualityScore(result: SearchResult) { const quality = result.source_metadata?.extraction_quality; if (quality === "partial") return -0.005; @@ -643,6 +666,7 @@ function extractionQualityScore(result: SearchResult) { return 0; } +/** Index quality rank signal. */ function indexQualityRankSignal(result: SearchResult) { const quality = result.indexing_quality; if (!quality) return 0; @@ -655,6 +679,7 @@ function indexQualityRankSignal(result: SearchResult) { return -0.09 - issuePenalty; } +/** Source quality rank signal. */ function sourceQualityRankSignal(result: SearchResult, queryClass: RagQueryClass) { let score = 0; const metadata = result.source_metadata; @@ -709,6 +734,7 @@ function sourceQualityRankSignal(result: SearchResult, queryClass: RagQueryClass return score; } +/** Evidence density boost. */ function evidenceDensityBoost(result: SearchResult, tokens: string[]) { if (!tokens.length) return 0; const haystack = normalizeQueryTokenForLookups( @@ -720,6 +746,7 @@ function evidenceDensityBoost(result: SearchResult, tokens: string[]) { return Math.min(0.1, hits * 0.028); } +/** Has dose evidence support. */ export function hasDoseEvidenceSupport(result: SearchResult) { const haystack = clinicalResultEvidenceHaystack(result); return /\b(?:dose|dosage|dosing|mg|mcg|microgram|route|oral|intramuscular|subcutaneous|subcut|sublingual|\bim\b|\bpo\b|\bsc\b|\bsl\b|\bprn\b|administer\w*|titration|titrate|frequency|maximum|tablet|injection|antipsychotic|benzodiazepine|olanzapine|lorazepam|haloperidol|droperidol|promethazine|diazepam)\b/i.test( @@ -727,6 +754,7 @@ export function hasDoseEvidenceSupport(result: SearchResult) { ); } +/** Has medication dose amount evidence. */ function hasMedicationDoseAmountEvidence(result: SearchResult) { const haystack = clinicalResultEvidenceHaystack(result); return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms)\b/i.test(haystack); @@ -737,6 +765,7 @@ function hasMedicationDoseAmountEvidence(result: SearchResult) { // answer to a dose/threshold query — and the least likely to repeat the drug // name. Such passages must be exempt from the dose/core-concept keyword // penalties so they are never demoted below boilerplate. See RET-H2. +/** Has numeric or table evidence. */ export function hasNumericOrTableEvidence(result: SearchResult) { if ((result.table_facts?.length ?? 0) > 0) return true; if ( @@ -757,6 +786,7 @@ export function hasNumericOrTableEvidence(result: SearchResult) { ); } +/** Has structured threshold evidence. */ export function hasStructuredThresholdEvidence(result: SearchResult) { const fieldType = result.match_explanation?.fieldType ?? null; const unitType = result.index_unit?.unit_type ?? result.match_explanation?.indexUnitType ?? null; @@ -787,6 +817,7 @@ export function hasStructuredThresholdEvidence(result: SearchResult) { ); } +/** Section depth signal. */ function sectionDepthSignal(querySignal: IntentSignals, sectionHeading: string | null) { if (!sectionHeading || !querySignal.sectionedLookup) return 0; if (/(protocol|procedure|pathway|workflow|algorithm|escalat|risk|monitor)/i.test(sectionHeading)) return 0.035; @@ -798,6 +829,7 @@ function sectionDepthSignal(querySignal: IntentSignals, sectionHeading: string | // though "review" is an escalation signal word. const explicitDoseTerms = ["dose", "dosage", "dosing", "titrat", "mg", "mcg"] as const; +/** Classify query intent. */ export function classifyQueryIntent(query: string): IntentSignals { const lowered = query.toLowerCase(); const match = intentPatterns.find((entry) => entry.pattern.test(query)); @@ -817,6 +849,7 @@ export function classifyQueryIntent(query: string): IntentSignals { const clinicalQueryAnalysisCache = new Map(); const clinicalQueryAnalysisCacheLimit = 32; +/** Analyze clinical query. */ export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { const originalQuery = query.trim(); const cached = clinicalQueryAnalysisCache.get(originalQuery); @@ -923,6 +956,7 @@ export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { return structuredClone(analysis); } +/** Classify rag query. */ export function classifyRagQuery(query: string): RagQueryClassification { const analysis = analyzeClinicalQuery(query); return { @@ -933,6 +967,7 @@ export function classifyRagQuery(query: string): RagQueryClassification { }; } +/** Image evidence signal. */ function imageEvidenceSignal(query: string, result: SearchResult) { const queryTokens = normalizedClinicalSearchTokens(query).join(" "); const hasImageQuery = /table|chart|flowchart|diagram|graph|appendix|figure|matrix/.test(queryTokens); @@ -957,6 +992,7 @@ function imageEvidenceSignal(query: string, result: SearchResult) { return 0; } +/** Section match boost. */ function sectionMatchBoost(query: string, result: SearchResult) { const loweredQuery = query.toLowerCase(); const sectionText = [result.section_heading, ...(result.section_path ?? [])].filter(Boolean).join(" "); @@ -972,6 +1008,7 @@ function sectionMatchBoost(query: string, result: SearchResult) { return Math.min(0.15, matches.length * 0.03 + (exactSectionPhrase ? 0.045 : 0)); } +/** Document metadata boost. */ function documentMetadataBoost(query: string, result: SearchResult, normalizedTokens: string[]) { if (!normalizedTokens.length) return 0; const queryClass = classifyRagQuery(query).queryClass; @@ -1012,6 +1049,7 @@ function documentMetadataBoost(query: string, result: SearchResult, normalizedTo ); } +/** Query class asset boost. */ function queryClassAssetBoost(args: { queryClass: RagQueryClass; normalizedQuery: string; result: SearchResult }) { const fieldType = args.result.match_explanation?.fieldType ?? null; const unitType = args.result.index_unit?.unit_type ?? args.result.match_explanation?.indexUnitType ?? null; @@ -1051,6 +1089,7 @@ function queryClassAssetBoost(args: { queryClass: RagQueryClass; normalizedQuery return Math.min(0.16, score); } +/** Normalized clinical search tokens. */ export function normalizedClinicalSearchTokens(query: string) { const baseTokens = correctedTokens(query); return baseTokens @@ -1059,6 +1098,7 @@ export function normalizedClinicalSearchTokens(query: string) { .filter((token) => (token.length > 2 || shortClinicalSearchTerms.has(token)) && !textSearchStopWords.has(token)); } +/** Build clinical text search query. */ export function buildClinicalTextSearchQuery(query: string) { const normalizedTokens = normalizedClinicalSearchTokens(query); const correctedQueryText = correctedTokens(query).join(" "); @@ -1152,6 +1192,7 @@ export function buildClinicalTextSearchQuery(query: string) { return uniqueTokens.length >= 1 ? uniqueTokens.join(" ") : query; } +/** Expand clinical query. */ export function expandClinicalQuery(query: string) { const analysis = analyzeClinicalQuery(query); const lowered = analysis.normalizedQuery; @@ -1171,10 +1212,12 @@ export function expandClinicalQuery(query: string) { return `${query} ${Array.from(additions).join(" ")}`; } +/** Round score. */ function roundScore(value: number) { return Number(value.toFixed(4)); } +/** Clinical rank explanation. */ export function clinicalRankExplanation(query: string, result: SearchResult): SearchScoreExplanation { const analysis = analyzeClinicalQuery(query); const queryTokens = tokens(query); @@ -1521,10 +1564,12 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se }; } +/** Clinical rank score. */ export function clinicalRankScore(query: string, result: SearchResult) { return clinicalRankExplanation(query, result).finalScore; } +/** Ranking tie break score. */ function rankingTieBreakScore(query: string, result: SearchResult, explanation: SearchScoreExplanation) { const analysis = analyzeClinicalQuery(query); const queryClass = analysis.queryClass; @@ -1664,6 +1709,7 @@ function rankingTieBreakScore(query: string, result: SearchResult, explanation: return roundScore(score); } +/** Rank clinical results. */ export function rankClinicalResults(query: string, results: SearchResult[]) { const intent = classifyQueryIntent(query); const wantsImageEvidence = hasImageEvidenceNeed(query) || intent.imageEvidenceFocus; diff --git a/src/lib/rag-extractive-answer.ts b/src/lib/rag-extractive-answer.ts index 4bca5db5e..1a6f22c8b 100644 --- a/src/lib/rag-extractive-answer.ts +++ b/src/lib/rag-extractive-answer.ts @@ -65,6 +65,7 @@ type ExtractedClinicalFact = { const extractiveLabelPattern = /\b(?:Medication point|Table evidence|Threshold\/action|Risk\/escalation|Workflow step|Section summary|Source point|Dose detail|Monitoring)\s*:\s*/gi; +/** Clean extractive point text. */ function cleanExtractivePointText(value: string) { return sourceTextForClinicalProse(value) .replace(/\b(?:clinical_table|table_crop|diagram_crop)\b/gi, " ") @@ -191,6 +192,7 @@ const answerIntentTerms = new Set([ "what", ]); +/** Classify answer intent. */ export function classifyAnswerIntent(query: string, queryClass: RagQueryClass): AnswerIntent { const normalized = normalizeSectionText(query).toLowerCase(); if (!normalized) return "unsupported"; @@ -242,16 +244,19 @@ export function classifyAnswerIntent(query: string, queryClass: RagQueryClass): return "general"; } +/** Query entity tokens. */ 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; } +/** Unique answer tokens. */ function uniqueAnswerTokens(tokens: string[]) { return Array.from(new Set(tokens.filter(Boolean))); } +/** Query intent tokens. */ 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)) @@ -265,6 +270,7 @@ function queryIntentTokens(query: string, intent: AnswerIntent) { return tokens; } +/** Answer intent evidence pattern. */ function answerIntentEvidencePattern(intent: AnswerIntent) { switch (intent) { case "dose": @@ -284,26 +290,33 @@ function answerIntentEvidencePattern(intent: AnswerIntent) { } } +/** Requires blood count evidence. */ function requiresBloodCountEvidence(query: string) { - return /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils)\b/i.test(query); + return /\b(?:anc|fbc|full blood count|blood count|wbc|wcc|white blood cells?|white cells?|neutrophils?)\b/i.test( + query, + ); } +/** Asks for withhold action. */ function asksForWithholdAction(query: string) { - return /\b(?:withhold|withheld|withholding|cease|stop|stopped|discontinue|discontinued)\b/i.test(query); + return /\b(?:withhold|withheld|withholding|hold|held|cease|stop|stopped|discontinue|discontinued)\b/i.test(query); } +/** Has blood count evidence. */ function hasBloodCountEvidence(text: string) { return /\b(?:anc|fbc|full blood count|wbc|wcc|white blood cell|white cell|neutrophil|neutrophils|blood count)\b/i.test( text, ); } +/** Has withhold action evidence. */ function hasWithholdActionEvidence(text: string) { - return /\b(?:withhold|withheld|withholding|cease|stop|stopped|discontinue|discontinued|red range|amber range|red|amber)\b/i.test( + return /\b(?:withhold|withheld|withholding|hold|held|cease|stop|stopped|discontinue|discontinued|red range|amber range)\b/i.test( text, ); } +/** Result covers answer intent. */ function resultCoversAnswerIntent(result: SearchResult, query: string, intent: AnswerIntent) { if (intent === "unsupported") return false; const text = evidenceTextForGate(result); @@ -341,14 +354,17 @@ const extractiveConcreteDosePattern = 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; +/** Extractive query tokens. */ function extractiveQueryTokens(query: string) { return splitBalancedWords(query).filter((token) => token.length > 2 && !extractiveQueryStopwords.has(token)); } +/** Escape query token. */ function escapeQueryToken(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** Query token variants. */ function queryTokenVariants(token: string) { const variants = new Set([token]); if (token.length > 5 && token.endsWith("ing")) variants.add(token.slice(0, -3)); @@ -358,6 +374,7 @@ function queryTokenVariants(token: string) { return [...variants].filter((variant) => variant.length > 2); } +/** Query token matches text. */ function queryTokenMatchesText(token: string, text: string) { if (token === "ect") return /\b(?:ect|electroconvulsive)\b/i.test(text); for (const variant of queryTokenVariants(token)) { @@ -370,17 +387,20 @@ function queryTokenMatchesText(token: string, text: string) { return false; } +/** Medication entities in text. */ function medicationEntitiesInText(text: string) { extractiveMedicationEntityPattern.lastIndex = 0; return Array.from(new Set((text.match(extractiveMedicationEntityPattern) ?? []).map((match) => match.toLowerCase()))); } +/** Mentions different medication entity. */ function mentionsDifferentMedicationEntity(sentence: string, query: string) { const queryMedicationEntities = medicationEntitiesInText(query); if (!queryMedicationEntities.length) return false; return medicationEntitiesInText(sentence).some((entity) => !queryMedicationEntities.includes(entity)); } +/** Has relevant query overlap. */ function hasRelevantQueryOverlap( text: string, query: string, @@ -404,6 +424,7 @@ function hasRelevantQueryOverlap( ); } +/** Has bad extractive quality. */ function hasBadExtractiveQuality(text: string) { const normalized = normalizeSectionText(text); if (!normalized) return true; @@ -475,6 +496,7 @@ function hasBadFinalAnswerQuality(text: string) { return false; } +/** Is low value extractive caption. */ function isLowValueExtractiveCaption(clause: string) { const descriptor = /^(?:clinical\s+table|table|figure|image)\s+(?:showing|detailing|listing|outlining|describing|with|of)\b/i.test( @@ -484,6 +506,7 @@ function isLowValueExtractiveCaption(clause: string) { return !extractiveClinicalDirectivePattern.test(clause); } +/** Split clinical evidence sentences. */ function splitClinicalEvidenceSentences(value: string) { return sourceTextForClinicalProse(value) .split(/\r?\n+|(?<=[.!?])\s+|\s+[•]\s+|\s+\|\s+/) @@ -497,6 +520,7 @@ function splitClinicalEvidenceSentences(value: string) { ); } +/** Fact kind for sentence. */ function factKindForSentence(sentence: string, query: string, intent: AnswerIntent): ExtractedClinicalFactKind | null { const text = normalizeSectionText(sentence); if (!text) return null; @@ -533,6 +557,7 @@ function factKindForSentence(sentence: string, query: string, intent: AnswerInte return null; } +/** Fact supports answer intent. */ function factSupportsAnswerIntent( kind: ExtractedClinicalFactKind, sentence: string, @@ -619,6 +644,7 @@ function factSupportsAnswerIntent( } } +/** Fact sentence matches query from result. */ function factSentenceMatchesQueryFromResult( sentence: string, result: SearchResult, @@ -644,6 +670,7 @@ function factSentenceMatchesQueryFromResult( return answerIntentEvidencePattern(intent).test(normalized) && intentCovered; } +/** Fact priority. */ function factPriority(kind: ExtractedClinicalFactKind, intent: AnswerIntent) { if (intent === "contraindication" && kind === "contraindication") return 9; if (intent === "red_result_action" && kind === "threshold_action") return 9; @@ -656,6 +683,7 @@ function factPriority(kind: ExtractedClinicalFactKind, intent: AnswerIntent) { return 6; } +/** Table facts to clinical facts. */ function tableFactsToClinicalFacts(result: SearchResult, query: string, intent: AnswerIntent): ExtractedClinicalFact[] { return (result.table_facts ?? []) .map((fact) => { @@ -675,6 +703,7 @@ function tableFactsToClinicalFacts(result: SearchResult, query: string, intent: .filter((fact): fact is ExtractedClinicalFact => Boolean(fact)); } +/** Extract clinical facts from results. */ function extractClinicalFactsFromResults(results: SearchResult[], query: string, intent: AnswerIntent, limit = 8) { const seen = new Set(); const facts: ExtractedClinicalFact[] = []; @@ -720,6 +749,7 @@ function extractClinicalFactsFromResults(results: SearchResult[], query: string, return facts.sort((a, b) => b.priority - a.priority || a.text.length - b.text.length).slice(0, limit); } +/** Sentence from fact. */ function sentenceFromFact(fact: ExtractedClinicalFact, query: string) { const text = sanitizeAnswerText(cleanExtractivePointText(fact.text)).replace(/[.;,\s]+$/, ""); if (!text) return ""; @@ -733,11 +763,13 @@ function sentenceFromFact(fact: ExtractedClinicalFact, query: string) { return completeExtractiveSentence(sentence, query); } +/** Lower first. */ function lowerFirst(value: string) { if (!value) return value; return `${value.charAt(0).toLowerCase()}${value.slice(1)}`; } +/** Upper first. */ function upperFirst(value: string) { if (!value) return value; return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; @@ -748,6 +780,7 @@ function upperFirst(value: string) { 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; +/** Complete extractive sentence. */ export function completeExtractiveSentence(value: string, query: string) { const cleaned = sanitizeAnswerText(value) .replace(/[.;,\s]+$/, "") @@ -784,6 +817,7 @@ export function completeExtractiveSentence(value: string, query: string) { return `The guidance is that ${lowerFirst(cleaned)}.`; } +/** Section for fact kind. */ function sectionForFactKind(kind: ExtractedClinicalFactKind): Pick { switch (kind) { case "dose": @@ -805,6 +839,7 @@ function sectionForFactKind(kind: ExtractedClinicalFactKind): Pick(); for (const fact of facts) grouped.set(fact.kind, [...(grouped.get(fact.kind) ?? []), fact]); @@ -828,6 +863,7 @@ function buildFactSections(facts: ExtractedClinicalFact[], query: string) { .filter((section) => section.body && section.citation_chunk_ids.length > 0); } +/** Build fact synthesized answer. */ function buildFactSynthesizedAnswer(args: { query: string; queryClass: RagQueryClass; @@ -864,6 +900,7 @@ function buildFactSynthesizedAnswer(args: { }; } +/** Source backed document fallback intent. */ function sourceBackedDocumentFallbackIntent( query: string, queryClass: RagQueryClass, @@ -887,6 +924,7 @@ function sourceBackedDocumentFallbackIntent( ); } +/** Document support list intent. */ function documentSupportListIntent(query: string, queryClass: RagQueryClass) { return ( classifyAnswerIntent(query, queryClass) === "document_lookup" && @@ -894,6 +932,7 @@ function documentSupportListIntent(query: string, queryClass: RagQueryClass) { ); } +/** Table or visual source lookup intent. */ function tableOrVisualSourceLookupIntent(query: string, queryClass: RagQueryClass, answerIntent: AnswerIntent) { if (queryClass === "table_threshold" || answerIntent === "dose" || answerIntent === "monitoring_schedule") return false; @@ -913,6 +952,7 @@ function tableOrVisualSourceLookupIntent(query: string, queryClass: RagQueryClas ); } +/** Source lookup label. */ function sourceLookupLabel(result: SearchResult) { const tableTitle = (result.table_facts ?? []) .map((fact) => fact.table_title || fact.row_label) @@ -927,6 +967,7 @@ function sourceLookupLabel(result: SearchResult) { .trim(); } +/** Has table or visual lookup evidence. */ function hasTableOrVisualLookupEvidence(result: SearchResult) { return ( (result.table_facts?.length ?? 0) > 0 || @@ -941,6 +982,7 @@ function hasTableOrVisualLookupEvidence(result: SearchResult) { ); } +/** Build table or visual source lookup answer. */ function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: SearchResult[] }) { const source = args.results.find(hasTableOrVisualLookupEvidence) ?? args.results[0]; if (!source) { @@ -967,6 +1009,7 @@ function buildTableOrVisualSourceLookupAnswer(args: { query: string; results: Se }; } +/** Build document support list answer. */ function buildDocumentSupportListAnswer(args: { query: string; results: SearchResult[] }) { const documents = buildDocumentBreakdown(args.results, extractQuoteCards(args.results, args.query)).slice(0, 5); if (!documents.length) { @@ -1019,6 +1062,7 @@ function buildDocumentSupportListAnswer(args: { query: string; results: SearchRe }; } +/** Build extractive answer. */ export function buildExtractiveAnswer(args: { query: string; queryClass: RagQueryClass; @@ -1121,6 +1165,7 @@ export function buildExtractiveAnswer(args: { } satisfies RagAnswer; } +/** Source backed fallback subject. */ function sourceBackedFallbackSubject(query: string) { const normalized = normalizeSectionText(query) .replace(/[?!.]+$/, "") @@ -1140,6 +1185,7 @@ function sourceBackedFallbackSubject(query: string) { return subject.length > 90 ? `${subject.slice(0, 87).trim()}...` : lowerFirst(subject); } +/** Source backed generation timeout answer. */ 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.`; @@ -1157,6 +1203,7 @@ const reasoningEffortRank: Record = { // 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. +/** Strong reasoning effort for query class. */ export function strongReasoningEffortForQueryClass( queryClass: RagQueryClass, configured: OpenAIReasoningEffort, @@ -1166,6 +1213,7 @@ export function strongReasoningEffortForQueryClass( return reasoningEffortRank[configured] > reasoningEffortRank.medium ? "medium" : configured; } +/** Is unusable generated answer. */ export function isUnusableGeneratedAnswer(answer: Pick) { const normalized = normalizeSectionText(answer.answer ?? ""); if (!normalized) return true; @@ -1184,6 +1232,7 @@ const simpleDirectQuestionPattern = 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; +/** Is template like generated answer. */ export function isTemplateLikeGeneratedAnswer(answer: Pick) { const answerText = normalizeSectionText(answer.answer ?? ""); if ( @@ -1203,6 +1252,7 @@ export function isTemplateLikeGeneratedAnswer(answer: Pick 100) return false; @@ -1217,14 +1267,17 @@ export function isSimpleDirectQuestion(query: string, queryClass: RagQueryClass) // 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. +/** Is bare definition question. */ export function isBareDefinitionQuestion(query: string) { return /^(?:what(?:'s| is| are)|define|who\s+(?:is|are))\b/i.test(normalizeSectionText(query)); } +/** Word count. */ function wordCount(value: string) { return normalizeSectionText(value).split(/\s+/).filter(Boolean).length; } +/** Is over expanded simple generated answer. */ export function isOverExpandedSimpleGeneratedAnswer( query: string, queryClass: RagQueryClass, @@ -1236,6 +1289,7 @@ export function isOverExpandedSimpleGeneratedAnswer( return nonEssentialSectionCount > 0 || sections.length > 1 || wordCount(answer.answer ?? "") > 95; } +/** Is essential simple question section. */ function isEssentialSimpleQuestionSection(section: Pick) { return /\b(?:gap|not enough|insufficient|unsupported|urgent|escalat\w*|risk|safety)\b/i.test( `${section.heading} ${section.body}`, @@ -1245,6 +1299,7 @@ function isEssentialSimpleQuestionSection(section: Pick) { return /\binvalid_model_citation_ids\b/.test(answer.routingReason ?? ""); } +/** Generated answer quality failure reason. */ export function generatedAnswerQualityFailureReason(answer: RagAnswer, query: string, queryClass: RagQueryClass) { const cleanedAnswer = sanitizeAnswerText(answer.answer); if (!cleanedAnswer) return "empty_after_sanitize"; @@ -1422,6 +1484,7 @@ export function generatedAnswerQualityFailureReason(answer: RagAnswer, query: st return null; } +/** Final quality failure. */ function finalQualityFailure(answer: RagAnswer, query: string, queryClass: RagQueryClass, reason: string): RagAnswer { return { ...answer, @@ -1434,6 +1497,7 @@ function finalQualityFailure(answer: RagAnswer, query: string, queryClass: RagQu }; } +/** Should preserve source backed generated answer. */ 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; @@ -1471,6 +1535,7 @@ function shouldPreserveSourceBackedGeneratedAnswer(answer: RagAnswer, reason: st return hasSpecificSourceSignal || hasStructuredChunk; } +/** Section heading kind. */ 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"; @@ -1480,6 +1545,7 @@ function sectionHeadingKind(heading: string): AnswerSectionKind { return "required_actions"; } +/** Clean answer section heading. */ export function cleanAnswerSectionHeading(heading: string, body: string) { const normalized = normalizeSectionText(heading); if ( @@ -1497,6 +1563,7 @@ export function cleanAnswerSectionHeading(heading: string, body: string) { return normalized; } +/** Apply provider labels. */ function applyProviderLabels(answer: RagAnswer): RagAnswer { const inferredSourceOnlyFallback = answer.routingMode === "extractive" || /(?:^|;\s*)generation_fallback(?::|$)/i.test(answer.routingReason ?? ""); @@ -1521,10 +1588,12 @@ function applyProviderLabels(answer: RagAnswer): RagAnswer { // 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. +/** Finalize rag answer quality. */ export function finalizeRagAnswerQuality(answer: RagAnswer, query: string, queryClass: RagQueryClass): RagAnswer { return applyProviderLabels(finalizeRagAnswerQualityCore(answer, query, queryClass)); } +/** Finalize rag answer quality core. */ 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. diff --git a/src/lib/rag-retrieval-variants.ts b/src/lib/rag-retrieval-variants.ts index 0831b361c..283c8301a 100644 --- a/src/lib/rag-retrieval-variants.ts +++ b/src/lib/rag-retrieval-variants.ts @@ -11,6 +11,7 @@ const ragAliasCacheTtlMs = 60_000; const maxRagAliasesPerScope = 200; const maxRagAliasExpansions = 12; +/** Text candidate budget for query class. */ 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); @@ -29,14 +30,17 @@ export type RagAliasInput = { const ragAliasCache = new Map(); +/** Normalize retrieval variant. */ export function normalizeRetrievalVariant(value: string) { return value.normalize("NFKC").replace(/\s+/g, " ").trim(); } +/** Retrieval variant from terms. */ function retrievalVariantFromTerms(terms: string[]) { return buildClinicalTextSearchQuery(terms.filter(Boolean).join(" ")); } +/** Normalize alias lookup. */ function normalizeAliasLookup(value: string) { return value .normalize("NFKC") @@ -45,10 +49,12 @@ function normalizeAliasLookup(value: string) { .trim(); } +/** Escaped alias pattern. */ function escapedAliasPattern(value: string) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"); } +/** Alias appears in query. */ function aliasAppearsInQuery(normalizedQuery: string, alias: string) { const normalizedAlias = normalizeAliasLookup(alias); if (!normalizedQuery || !normalizedAlias) return false; @@ -56,6 +62,7 @@ function aliasAppearsInQuery(normalizedQuery: string, alias: string) { return pattern.test(normalizedQuery); } +/** Select rag alias expansions. */ export function selectRagAliasExpansions(query: string, aliases: RagAliasInput[], limit = maxRagAliasExpansions) { const normalizedQuery = normalizeAliasLookup(query); const expansions: string[] = []; @@ -80,6 +87,7 @@ export function selectRagAliasExpansions(query: string, aliases: RagAliasInput[] return expansions; } +/** Should apply unsupported search short circuit. */ export function shouldApplyUnsupportedSearchShortCircuit( query: string, analysis: ClinicalQueryAnalysis, @@ -88,6 +96,7 @@ export function shouldApplyUnsupportedSearchShortCircuit( return aliasExpansions.length === 0 && shouldShortCircuitUnsupportedSearch(query, analysis); } +/** Fetch enabled rag aliases. */ export async function fetchEnabledRagAliases( supabase: ReturnType, ownerId?: string, @@ -96,6 +105,7 @@ export async function fetchEnabledRagAliases( const cached = ragAliasCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) return cached.aliases; + /** Read scope. */ async function readScope(scopeOwnerId: string | null) { let query = supabase .from("rag_aliases") @@ -133,6 +143,7 @@ export async function fetchEnabledRagAliases( } } +/** Assert global search allowed. */ export function assertGlobalSearchAllowed(args: SearchChunksArgs) { if (args.ownerId || args.allowGlobalSearch || isDemoMode() || isLocalNoAuthMode()) return; if (process.env.NODE_ENV === "production") { @@ -140,6 +151,7 @@ export function assertGlobalSearchAllowed(args: SearchChunksArgs) { } } +/** Owner scope for document filtered retrieval. */ export function ownerScopeForDocumentFilteredRetrieval( ownerId: string | undefined, documentIds: string[] | undefined, @@ -148,6 +160,7 @@ export function ownerScopeForDocumentFilteredRetrieval( return retrievalOwnerFilter({ ownerId, documentIds, allowGlobalSearch }); } +/** Build retrieval query variants. */ export function buildRetrievalQueryVariants( query: string, analysis: ClinicalQueryAnalysis, @@ -261,6 +274,7 @@ export function buildRetrievalQueryVariants( // 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. +/** Relax variant to or query. */ export function relaxVariantToOrQuery(variant: string): string | null { const tokens = Array.from( new Set( @@ -290,6 +304,7 @@ const strongTextMatchTopRankBar = 0.3; // 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. +/** Should relax weak text matches. */ 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); diff --git a/src/lib/rag-routing.ts b/src/lib/rag-routing.ts index e38f90b9b..e52422e6f 100644 --- a/src/lib/rag-routing.ts +++ b/src/lib/rag-routing.ts @@ -166,22 +166,27 @@ const adversarialManipulationPatterns: RegExp[] = [ /\b(?:what(?:'s|\s+is|\s+are)?|your|any|the)\b[^.?!]{0,20}\b(?:hidden\s+)?(?:system\s+prompt|developer\s+(?:prompt|message|instructions?)|api\s+keys?|access\s+tokens?)\b/i, ]; +/** Has adversarial manipulation intent. */ export function hasAdversarialManipulationIntent(query: string): boolean { return adversarialManipulationPatterns.some((pattern) => pattern.test(query)); } +/** Strongest retrieval score. */ export function strongestRetrievalScore(results: SearchResult[]) { return results.reduce((max, result) => Math.max(max, result.hybrid_score ?? result.similarity), 0); } +/** Document count. */ function documentCount(results: SearchResult[]) { return new Set(results.map((result) => result.document_id)).size; } +/** Has text support. */ function hasTextSupport(results: SearchResult[]) { return results.some((result) => (result.text_rank ?? 0) > 0.05 || (result.hybrid_score ?? 0) >= 0.32); } +/** Has actionable conflict or gap. */ function hasActionableConflictOrGap(conflictsOrGaps: ConflictOrGap[] = []) { return conflictsOrGaps.some( (item) => @@ -189,10 +194,12 @@ function hasActionableConflictOrGap(conflictsOrGaps: ConflictOrGap[] = []) { ); } +/** Has conflict intent. */ function hasConflictIntent(query: string) { return /\b(?:conflict|gap|contradict|disagree|inconsisten|versus|vs)\b/i.test(query); } +/** Has explicit document lookup intent. */ function hasExplicitDocumentLookupIntent(query: string) { return ( /\b(?:find|search|lookup|open|show)\b.{0,80}\b(?:document|file|pdf|protocol|guideline|procedure)\b/i.test(query) || @@ -200,6 +207,7 @@ function hasExplicitDocumentLookupIntent(query: string) { ); } +/** Has source support lookup intent. */ function hasSourceSupportLookupIntent(query: string) { return ( /\b(?:what|which)\s+(?:documents?|sources?|files?|guidelines?)\b.{0,120}\b(?:support|supports|supporting|cover|covers|contain|contains|mention|mentions)\b/i.test( @@ -208,12 +216,14 @@ function hasSourceSupportLookupIntent(query: string) { ); } +/** Has quote or source location intent. */ function hasQuoteOrSourceLocationIntent(query: string) { return /\b(?:quote|quotes|quoted|exact wording|source location|where in|which page|page number|open source|show source|source link|citation|citations)\b/i.test( query, ); } +/** Has explicit table or visual lookup intent. */ function hasExplicitTableOrVisualLookupIntent(query: string) { return ( /\b(?:which|what|show|find|open|where)\b.{0,120}\b(?:table|chart|flow\s*chart|flowchart|figure|appendix|form)\b/i.test( @@ -225,6 +235,7 @@ function hasExplicitTableOrVisualLookupIntent(query: string) { ); } +/** Normalize lookup text. */ function normalizeLookupText(text: string) { return text .replace(/([a-z])([A-Z])/g, "$1 $2") @@ -233,6 +244,7 @@ function normalizeLookupText(text: string) { .trim(); } +/** Query topic tokens. */ function queryTopicTokens(query: string) { return normalizeLookupText(query) .split(/\s+/) @@ -240,13 +252,15 @@ function queryTopicTokens(query: string) { .filter((token) => token.length > 2 && !queryStopWords.has(token)); } +/** Query specific title tokens. */ function querySpecificTitleTokens(query: string) { return normalizeLookupText(query) .split(/\s+/) - .filter((token) => token.length > 2 && !specificTitleStopWords.has(token)) - .map((token) => token.replace(/s$/, "")); + .map((token) => token.replace(/s$/, "")) + .filter((token) => token.length > 2 && !specificTitleStopWords.has(token)); } +/** Has direct title support. */ export function hasDirectTitleSupport(query: string, results: SearchResult[]) { const tokens = queryTopicTokens(query); if (tokens.length === 0) return false; @@ -257,6 +271,7 @@ export function hasDirectTitleSupport(query: string, results: SearchResult[]) { }); } +/** Best title token match. */ function bestTitleTokenMatch(query: string, results: SearchResult[]) { const tokens = querySpecificTitleTokens(query); let matchedCount = 0; @@ -270,6 +285,7 @@ function bestTitleTokenMatch(query: string, results: SearchResult[]) { return { tokenCount: tokens.length, matchedCount }; } +/** Has specific title support. */ export function hasSpecificTitleSupport(query: string, results: SearchResult[]) { const { tokenCount, matchedCount } = bestTitleTokenMatch(query, results); if (tokenCount === 0) return false; @@ -277,15 +293,18 @@ export function hasSpecificTitleSupport(query: string, results: SearchResult[]) return matchedCount >= 2 || matchedCount / tokenCount >= 0.5; } +/** Has admission community lookup intent. */ function hasAdmissionCommunityLookupIntent(query: string) { const normalized = normalizeLookupText(query); return /\badmission\b/.test(normalized) && /\bcommunity\b/.test(normalized); } +/** Is complex clinical query. */ export function isComplexClinicalQuery(query: string) { return complexClinicalQueryPattern.test(query); } +/** Has table or visual source support. */ function hasTableOrVisualSourceSupport(results: SearchResult[]) { return results.slice(0, 8).some((result) => { const reasonText = (result.match_explanation?.reasons ?? []).join(" "); @@ -306,6 +325,7 @@ function hasTableOrVisualSourceSupport(results: SearchResult[]) { }); } +/** Is clozapine blood withhold threshold query. */ function isClozapineBloodWithholdThresholdQuery(query: string) { return ( clozapineBloodWithholdThresholdPattern.test(query) && @@ -314,35 +334,51 @@ function isClozapineBloodWithholdThresholdQuery(query: string) { ); } -function hasExplicitBloodWithholdActionEvidence(results: SearchResult[]) { - return results.slice(0, 8).some((result) => { - const tableFactText = (result.table_facts ?? []) - .map((fact) => - [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action] - .filter(Boolean) - .join(" "), - ) - .join(" "); - const sourceText = [ - result.title, - result.file_name, - result.section_heading, - result.retrieval_synopsis, - result.content, - result.adjacent_context, - tableFactText, - result.index_unit?.title, - result.index_unit?.content, - ] +const explicitWithholdActionPattern = + /\b(?:withhold|withheld|withholding|hold|held|cease|stop|stopped|discontinue|discontinued)\b/i; + +/** Snippet has co occurring blood withhold evidence. */ +function snippetHasCoOccurringBloodWithholdEvidence(snippet: string) { + const sentences = snippet + .split(/(?<=[.!?])\s+|\r?\n+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); + const candidates = sentences.length > 0 ? sentences : [snippet]; + return candidates.some( + (sentence) => bloodCountTermPattern.test(sentence) && explicitWithholdActionPattern.test(sentence), + ); +} + +/** Blood withhold evidence snippets. */ +function bloodWithholdEvidenceSnippets(result: SearchResult) { + const tableFactSnippets = (result.table_facts ?? []).map((fact) => + [fact.table_title, fact.row_label, fact.clinical_parameter, fact.threshold_value, fact.action] .filter(Boolean) - .join(" "); - return ( - bloodCountTermPattern.test(sourceText) && - /\b(?:withhold|withheld|withholding|cease|stop|stopped|discontinue|discontinued)\b/i.test(sourceText) + .join(" "), + ); + return [ + result.title, + result.file_name, + result.section_heading, + result.retrieval_synopsis, + result.content, + result.adjacent_context, + result.index_unit?.title, + result.index_unit?.content, + ...tableFactSnippets, + ].filter((value): value is string => Boolean(value)); +} + +/** Has explicit blood withhold action evidence. */ +function hasExplicitBloodWithholdActionEvidence(results: SearchResult[]) { + return results + .slice(0, 8) + .some((result) => + bloodWithholdEvidenceSnippets(result).some((snippet) => snippetHasCoOccurringBloodWithholdEvidence(snippet)), ); - }); } +/** Should prefer model synthesis. */ function shouldPreferModelSynthesis(query: string, queryClass: RagQueryClass) { return ( queryClass === "medication_dose_risk" || @@ -355,6 +391,7 @@ function shouldPreferModelSynthesis(query: string, queryClass: RagQueryClass) { ); } +/** Should use extractive medication lookup. */ function shouldUseExtractiveMedicationLookup(args: { query: string; results: SearchResult[]; @@ -368,6 +405,7 @@ function shouldUseExtractiveMedicationLookup(args: { return args.directTitleSupport || args.topTextRank >= 0.08 || hasTableOrVisualSourceSupport(args.results); } +/** Should use strong clinical route. */ function shouldUseStrongClinicalRoute(args: { query: string; queryClass: RagQueryClass; @@ -383,6 +421,7 @@ function shouldUseStrongClinicalRoute(args: { return false; } +/** Should use extractive answer. */ export function shouldUseExtractiveAnswer(args: { query: string; results: SearchResult[]; @@ -430,6 +469,7 @@ export function shouldUseExtractiveAnswer(args: { return false; } +/** Choose answer route. */ export function chooseAnswerRoute(args: { query: string; results: SearchResult[]; @@ -621,7 +661,12 @@ export function chooseAnswerRoute(args: { }; } - if (queryClass === "table_threshold" && directTitleSupport && strongestScore >= strongRetrievalThreshold) { + if ( + queryClass === "table_threshold" && + directTitleSupport && + strongestScore >= strongRetrievalThreshold && + !actionableConflictOrGap + ) { return { mode: "extractive", model: null, @@ -784,6 +829,7 @@ export function chooseAnswerRoute(args: { }; } +/** Should retry with strong after fast. */ export function shouldRetryWithStrongAfterFast(args: { route: AnswerRoute; answer: Pick; @@ -801,6 +847,7 @@ export function shouldRetryWithStrongAfterFast(args: { return solidSourceSupport && args.results.length >= 2; } +/** Append routing reason. */ export function appendRoutingReason(reason: string | undefined, addition: string) { return reason ? `${reason}; ${addition}` : addition; } diff --git a/src/lib/rag.ts b/src/lib/rag.ts index a359f4592..2092186b5 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -321,6 +321,7 @@ const answerJsonOutputSchema = { required: ["answer", "grounded", "confidence", "answerSections", "citations", "quoteCards", "conflictsOrGaps"], }; +/** Answer json output schema for results. */ export function answerJsonOutputSchemaForResults(results: SearchResult[]) { const chunkIds = Array.from(new Set(results.map((result) => result.id).filter(Boolean))); if (chunkIds.length === 0) return answerJsonOutputSchema; @@ -353,6 +354,7 @@ const confidenceOrder = { export const machineReadableFallbackAnswer = "The indexed sources were not machine-readable enough to produce a formatted answer."; +/** Throw if aborted. */ function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) { throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); @@ -481,6 +483,7 @@ export type SearchTelemetry = { | "unsupported_short_circuit"; }; +/** Retrieval plan for query class. */ export function retrievalPlanForQueryClass(queryClass?: RagQueryClass) { switch (queryClass) { case "document_lookup": @@ -516,6 +519,7 @@ const tableVisualEvidenceUnitTypes = new Set([ "risk_matrix_cell", ]); +/** Provenance layer keys. */ function provenanceLayerKeys(result: SearchResult) { const layers = new Set(["chunk"]); if (result.memory_cards?.length) layers.add("memory_card"); @@ -531,10 +535,12 @@ function provenanceLayerKeys(result: SearchResult) { return layers; } +/** Layer top score. */ function layerTopScore(results: SearchResult[]) { return Number(Math.max(0, ...results.map((result) => result.hybrid_score ?? result.similarity ?? 0)).toFixed(4)); } +/** Record retrieval layer. */ function recordRetrievalLayer( telemetry: SearchTelemetry, layer: string, @@ -565,6 +571,7 @@ function recordRetrievalLayer( // where telemetry is in scope, record the failing RPC + code so it shows up in rag_retrieval_logs. type SupabaseRpcError = { message?: string; code?: string; details?: string; hint?: string } | null; +/** Record hybrid rpc error. */ function recordHybridRpcError(telemetry: SearchTelemetry | undefined, rpc: string, error: SupabaseRpcError) { if (!error) return; const code = error.code ?? "unknown"; @@ -577,6 +584,7 @@ function recordHybridRpcError(telemetry: SearchTelemetry | undefined, rpc: strin } } +/** Record search score telemetry. */ function recordSearchScoreTelemetry(telemetry: SearchTelemetry, results: SearchResult[]) { if (!results.length) { telemetry.top_score = 0; @@ -645,6 +653,7 @@ function recordSearchScoreTelemetry(telemetry: SearchTelemetry, results: SearchR }, 0); } +/** Should use second stage rerank. */ function shouldUseSecondStageRerank(queryClass: RagQueryClass | undefined, results: SearchResult[], topK: number) { if (results.length <= 1) return false; const topScore = Math.max(0, results[0]?.hybrid_score ?? results[0]?.similarity ?? 0); @@ -661,6 +670,7 @@ function shouldUseSecondStageRerank(queryClass: RagQueryClass | undefined, resul return topScoresClose && hasVisualEvidence; } +/** Second stage score. */ function secondStageScore(result: SearchResult, queryClass: RagQueryClass | undefined, index: number) { let score = result.score_explanation?.finalScore ?? result.hybrid_score ?? result.similarity ?? 0; const unitType = result.index_unit?.unit_type ?? ""; @@ -700,6 +710,7 @@ function secondStageScore(result: SearchResult, queryClass: RagQueryClass | unde return score; } +/** Apply second stage rerank if needed. */ function applySecondStageRerankIfNeeded(args: { queryClass?: RagQueryClass; results: SearchResult[]; @@ -812,6 +823,7 @@ const answerJsonSchema = z.object({ // hybrid score via scoreValue, not a pure cosine — a stricter pure-`similarity` gate would change // behaviour and needs eval validation.) Ordering, routing, and coverage gates are untouched — this // only stops the fabricated scale from masquerading as strong semantic evidence in the label. +/** Derive confidence. */ export function deriveConfidence( results: SearchResult[], acceptedCitations: Array>, @@ -829,6 +841,7 @@ export function deriveConfidence( return "low"; } +/** Score value. */ export function scoreValue(result: SearchResult) { const similarity = result.similarity ?? 0; const hybrid = result.hybrid_score ?? similarity; @@ -836,6 +849,7 @@ export function scoreValue(result: SearchResult) { return Math.min(1, hybrid); } +/** Build retrieval diagnostics. */ function buildRetrievalDiagnostics(args: { queryClass: RagQueryClass; query: string; @@ -882,6 +896,7 @@ function buildRetrievalDiagnostics(args: { } satisfies RetrievalDiagnostics; } +/** Apply confidence gate. */ function applyConfidenceGate( route: { mode: "unsupported" | "extractive" | "fast" | "strong"; @@ -908,6 +923,7 @@ function applyConfidenceGate( }; } +/** Clamp confidence. */ function clampConfidence( proposed: RagAnswer["confidence"] | undefined, derived: RagAnswer["confidence"], @@ -924,6 +940,7 @@ type SanitizedCitations = { invalidCount: number; }; +/** Sanitize citations. */ function sanitizeCitations( proposed: Array<{ chunk_id: string }> | undefined, results: SearchResult[], @@ -950,6 +967,7 @@ function sanitizeCitations( return { citations: [], modelCited: false, proposedCount, invalidCount }; } +/** Infer answer section kind. */ function inferAnswerSectionKind( proposed: AnswerSectionKind | undefined, heading: string, @@ -971,6 +989,7 @@ function inferAnswerSectionKind( return "bottom_line"; } +/** Normalize answer section support level. */ function normalizeAnswerSectionSupportLevel( proposed: AnswerSectionSupportLevel | undefined, sources: SearchResult[], @@ -984,6 +1003,7 @@ function normalizeAnswerSectionSupportLevel( return sources.length ? "direct" : "unsupported"; } +/** Remove incomplete trailing sentence. */ function removeIncompleteTrailingSentence(value: string) { const text = value.trim(); if (!text || /[.!?]["')\]]*$/.test(text)) return text; @@ -996,6 +1016,7 @@ function removeIncompleteTrailingSentence(value: string) { return complete.length >= 32 ? complete : text; } +/** Sanitize answer section heading text. */ function sanitizeAnswerSectionHeadingText(heading: string, body: string) { const structuredHeading = sanitizeStructuredText(heading, { minLength: 1, minTokens: 1 }); const polishedHeading = structuredHeading ? sanitizeAnswerText(structuredHeading) || structuredHeading : ""; @@ -1008,6 +1029,7 @@ function sanitizeAnswerSectionHeadingText(heading: string, body: string) { return cleanAnswerSectionHeading(usableHeading, body); } +/** Sanitize answer sections. */ function sanitizeAnswerSections( sections: AnswerSection[] | undefined, results: SearchResult[], @@ -1046,6 +1068,7 @@ function sanitizeAnswerSections( return true; }); } +/** Normalize search results. */ function normalizeSearchResults(results: SearchResult[]) { return results.map((result) => ({ ...result, @@ -1053,6 +1076,7 @@ function normalizeSearchResults(results: SearchResult[]) { })); } +/** Safe fallback answer. */ function safeFallbackAnswer(raw: string, results: SearchResult[], query?: string): RagAnswer { // B5: on model-JSON parse failure we cannot trust any model-asserted citation // mapping. Do NOT back-fill all retrieved chunks as citations and stamp the @@ -1075,6 +1099,7 @@ function safeFallbackAnswer(raw: string, results: SearchResult[], query?: string return applyNumericVerification(answer); } +/** Add OpenAI usage. */ function addOpenAIUsage(total: OpenAITokenUsage, usage?: OpenAITokenUsage) { if (!usage) return total; return { @@ -1086,10 +1111,12 @@ function addOpenAIUsage(total: OpenAITokenUsage, usage?: OpenAITokenUsage) { }; } +/** Has OpenAI usage. */ function hasOpenAIUsage(usage: OpenAITokenUsage) { return Object.values(usage).some((value) => typeof value === "number" && value > 0); } +/** Fallback reason from routing. */ export function fallbackReasonFromRouting(reason?: string | null) { if (!reason) return null; return ( @@ -1160,6 +1187,7 @@ const queryClassifierParseSchema = z.object({ expandedTerms: z.array(z.string()).max(10), }); +/** Unique text values. */ function uniqueTextValues(values: Array, limit = 32) { const seen = new Set(); const output: string[] = []; @@ -1190,6 +1218,7 @@ const classifierVerdictMemoMaxEntries = 500; const classifierVerdictMemo = new Map(); const classifierVerdictInflight = new Map>(); +/** Classifier verdict memo key. */ function classifierVerdictMemoKey(query: string, analysis: ClinicalQueryAnalysis) { const normalizedQuery = query.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim(); // The deterministic class + confidence bucket are part of the key so a deterministic-analyzer @@ -1197,6 +1226,7 @@ function classifierVerdictMemoKey(query: string, analysis: ClinicalQueryAnalysis return `${normalizedQuery}::${analysis.queryClass}::${analysis.confidence.toFixed(2)}`; } +/** Store classifier verdict memo. */ function storeClassifierVerdictMemo(key: string, verdict: ClassifierVerdict) { if (classifierVerdictMemo.size >= classifierVerdictMemoMaxEntries) { const oldestKey = classifierVerdictMemo.keys().next().value; @@ -1205,11 +1235,13 @@ function storeClassifierVerdictMemo(key: string, verdict: ClassifierVerdict) { classifierVerdictMemo.set(key, { expiresAt: Date.now() + classifierVerdictMemoTtlMs, verdict }); } +/** Reset classifier verdict memo for tests. */ export function resetClassifierVerdictMemoForTests() { classifierVerdictMemo.clear(); classifierVerdictInflight.clear(); } +/** Request classifier verdict. */ async function requestClassifierVerdict(query: string, analysis: ClinicalQueryAnalysis): Promise { const result = await generateStructuredTextResult( [ @@ -1245,6 +1277,7 @@ async function requestClassifierVerdict(query: string, analysis: ClinicalQueryAn return queryClassifierParseSchema.parse(JSON.parse(result.text)); } +/** Apply classifier verdict. */ function applyClassifierVerdict(analysis: ClinicalQueryAnalysis, parsed: ClassifierVerdict): ClinicalQueryAnalysis { if (parsed.confidence < 0.58 || parsed.queryClass === "unsupported_or_general") return analysis; return { @@ -1271,6 +1304,7 @@ function applyClassifierVerdict(analysis: ClinicalQueryAnalysis, parsed: Classif } satisfies ClinicalQueryAnalysis; } +/** Analyze query with classifier fallback. */ export async function analyzeQueryWithClassifierFallback( query: string, analysis: ClinicalQueryAnalysis, @@ -1362,6 +1396,7 @@ export async function analyzeQueryWithClassifierFallback( // Shared eligibility gate for the unsupported soft tail: a low-signal unsupported_or_general // analysis with no title/medication/threshold intent and no non-default reasons. +/** Unsupported soft tail eligible. */ function unsupportedSoftTailEligible(analysis: ClinicalQueryAnalysis) { if (analysis.queryClass !== "unsupported_or_general") return false; if (analysis.documentTitleIntent || analysis.medications.length || analysis.thresholdTerms.length) return false; @@ -1369,6 +1404,7 @@ function unsupportedSoftTailEligible(analysis: ClinicalQueryAnalysis) { return true; } +/** Should short circuit unsupported search. */ export function shouldShortCircuitUnsupportedSearch(query: string, analysis: ClinicalQueryAnalysis) { if (unavailableDocumentNoisePattern.test(query)) return true; if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return true; @@ -1381,6 +1417,7 @@ export function shouldShortCircuitUnsupportedSearch(query: string, analysis: Cli // pattern guards (out-of-corpus medical list, consumer-noise, unavailable-document noise). // Corpus grounding is scoped to exactly this branch: the pattern-guarded refusals and every // higher-confidence class keep their existing behaviour untouched. +/** Is unsupported soft tail analysis. */ function isUnsupportedSoftTailAnalysis(query: string, analysis: ClinicalQueryAnalysis) { if (unavailableDocumentNoisePattern.test(query)) return false; if (clearlyOutsideCorpusMedicalPattern.test(query) && analysis.documentTitleTerms.length === 0) return false; @@ -1389,6 +1426,7 @@ function isUnsupportedSoftTailAnalysis(query: string, analysis: ClinicalQueryAna return analysis.confidence <= 0.42 && analysis.expandedTerms.length <= 5; } +/** Metadata expansion term score. */ function metadataExpansionTermScore(queryTokens: Set, value: string, sourceWeight: number) { const tokens = normalizedClinicalSearchTokens(value); if (tokens.length === 0) return 0; @@ -1397,6 +1435,7 @@ function metadataExpansionTermScore(queryTokens: Set, value: string, sou return sourceWeight + overlap * 0.6 + compactness; } +/** Candidate metadata expansion terms. */ function candidateMetadataExpansionTerms(query: string, candidates: SearchResult[], limit = 12) { const queryTokens = new Set(normalizedClinicalSearchTokens(query)); const scoredTerms: Array<{ value: string; score: number }> = []; @@ -1439,6 +1478,7 @@ function candidateMetadataExpansionTerms(query: string, candidates: SearchResult ); } +/** Expand clinical query with candidate metadata. */ function expandClinicalQueryWithCandidateMetadata(query: string, expandedQuery: string, candidates: SearchResult[]) { const metadataTerms = candidateMetadataExpansionTerms(query, candidates); if (metadataTerms.length === 0) return expandedQuery; @@ -1449,6 +1489,7 @@ type RagQueryInsert = Omit; }; +/** Insert rag query. */ async function insertRagQuery(row: RagQueryInsert) { const supabase = createAdminClient(); // Redact potential-PHI raw query text centrally so every logRagQuery caller is @@ -1462,6 +1503,7 @@ async function insertRagQuery(row: RagQueryInsert) { await supabase.from("rag_queries").insert(safeRow); } +/** Log rag query. */ async function logRagQuery(row: RagQueryInsert) { if (env.RAG_AWAIT_QUERY_LOGS) { await insertRagQuery(row); @@ -1471,6 +1513,7 @@ async function logRagQuery(row: RagQueryInsert) { void insertRagQuery(row).catch(() => undefined); } +/** Merge search results. */ function mergeSearchResults(primary: SearchResult[], secondary: SearchResult[]) { const merged = new Map(); @@ -1489,6 +1532,7 @@ function mergeSearchResults(primary: SearchResult[], secondary: SearchResult[]) return Array.from(merged.values()); } +/** Search text chunk candidates. */ async function searchTextChunkCandidates(args: { supabase: ReturnType; queryVariants: string[]; @@ -1648,6 +1692,7 @@ type TableFactRpcRow = { metadata?: Record | null; }; +/** Document lookup chunk terms. */ function documentLookupChunkTerms(query: string) { const shortClinicalTerms = new Set(["ed", "im", "po", "pt"]); return normalizedClinicalSearchTokens(query) @@ -1655,6 +1700,7 @@ function documentLookupChunkTerms(query: string) { .slice(0, 8); } +/** Document lookup chunk score. */ function documentLookupChunkScore(chunk: DocumentLookupChunkRow, terms: string[]) { if (terms.length === 0) return 0; const heading = chunk.section_heading?.toLowerCase() ?? ""; @@ -1665,6 +1711,7 @@ function documentLookupChunkScore(chunk: DocumentLookupChunkRow, terms: string[] return coverage + headingHits * 0.08 + Math.max(0, 0.08 - chunk.chunk_index * 0.0005); } +/** Fetch best document lookup chunks. */ async function fetchBestDocumentLookupChunks(args: { supabase: ReturnType; documentIds: string[]; @@ -1734,6 +1781,7 @@ async function fetchBestDocumentLookupChunks(args: { return { chunks: fallbackChunks as DocumentLookupChunkRow[], terms }; } +/** Fetch document title alias rows. */ async function fetchDocumentTitleAliasRows(args: { supabase: ReturnType; query: string; @@ -1765,6 +1813,7 @@ async function fetchDocumentTitleAliasRows(args: { })); } +/** Search document lookup fast path. */ async function searchDocumentLookupFastPath(args: { supabase: ReturnType; query: string; @@ -1869,6 +1918,7 @@ async function searchDocumentLookupFastPath(args: { .slice(0, args.matchCount); } +/** Collect memory cards. */ export function collectMemoryCards(results: SearchResult[], limit = 8) { const seen = new Set(); const cards: DocumentMemoryCard[] = []; @@ -1884,6 +1934,7 @@ export function collectMemoryCards(results: SearchResult[], limit = 8) { return cards; } +/** Build indexing quality. */ export function buildIndexingQuality(results: SearchResult[], memoryCards: DocumentMemoryCard[]): DocumentIndexQuality { const sourceMetadata = results.map((result) => normalizeSourceMetadata(result.source_metadata)); const indexedQualityRows = results @@ -1928,6 +1979,7 @@ export function buildIndexingQuality(results: SearchResult[], memoryCards: Docum }; } +/** Build answer score explanations. */ export function buildAnswerScoreExplanations( results: SearchResult[], limit = 8, @@ -1942,6 +1994,7 @@ export function buildAnswerScoreExplanations( })); } +/** Score explanation log metadata. */ function scoreExplanationLogMetadata(scoreExplanations: NonNullable) { return { score_explanation_count: scoreExplanations.length, @@ -1967,12 +2020,14 @@ function scoreExplanationLogMetadata(scoreExplanations: NonNullable 0) return Math.min(1, hybridScore); return Math.min(1, card.confidence ?? 0.5); } +/** Load chunks for memory cards. */ async function loadChunksForMemoryCards( supabase: ReturnType, cards: DocumentMemoryCard[], @@ -2044,6 +2099,7 @@ async function loadChunksForMemoryCards( .filter(Boolean) as SearchResult[]; } +/** Load chunks for signal matches. */ async function loadChunksForSignalMatches(args: { supabase: ReturnType; matches: ChunkSignalMatch[]; @@ -2129,6 +2185,7 @@ async function loadChunksForSignalMatches(args: { .filter(Boolean) as SearchResult[]; } +/** Search table fact candidates. */ async function searchTableFactCandidates(args: { supabase: ReturnType; query: string; @@ -2181,6 +2238,7 @@ async function searchTableFactCandidates(args: { }); } +/** Search embedding field candidates. */ async function searchEmbeddingFieldCandidates(args: { supabase: ReturnType; query: string; @@ -2232,6 +2290,7 @@ async function searchEmbeddingFieldCandidates(args: { return loadChunksForSignalMatches({ supabase: args.supabase, matches, ownerId: args.ownerId }); } +/** Search index unit candidates. */ async function searchIndexUnitCandidates(args: { supabase: ReturnType; query: string; @@ -2287,6 +2346,7 @@ async function searchIndexUnitCandidates(args: { type MemoryCardCache = Map>; +/** With memory boosted candidates. */ async function withMemoryBoostedCandidates(args: { supabase: ReturnType; query: string; @@ -2339,6 +2399,7 @@ type DocumentRankingMetadataCache = { indexQuality: Map; }; +/** Create document ranking metadata cache. */ function createDocumentRankingMetadataCache(): DocumentRankingMetadataCache { return { documentMetadata: new Map(), @@ -2346,6 +2407,7 @@ function createDocumentRankingMetadataCache(): DocumentRankingMetadataCache { }; } +/** Attach document ranking metadata. */ async function attachDocumentRankingMetadata( supabase: ReturnType, results: SearchResult[], @@ -2408,6 +2470,7 @@ async function attachDocumentRankingMetadata( } } +/** With cached index quality. */ function withCachedIndexQuality(results: SearchResult[], cache: DocumentRankingMetadataCache) { return results.map((result) => ({ ...result, @@ -2415,6 +2478,7 @@ function withCachedIndexQuality(results: SearchResult[], cache: DocumentRankingM })); } +/** Attach index quality metadata. */ async function attachIndexQualityMetadata( supabase: ReturnType, results: SearchResult[], @@ -2441,6 +2505,7 @@ async function attachIndexQualityMetadata( } } +/** Attach page visual evidence. */ async function attachPageVisualEvidence( supabase: ReturnType, results: SearchResult[], @@ -2562,6 +2627,7 @@ async function attachPageVisualEvidence( }); } +/** Decide text fast path. */ export function decideTextFastPath( query: string, results: SearchResult[], @@ -2641,6 +2707,7 @@ export function decideTextFastPath( return { returnFastPath: false, reason: "weak_text_match" }; } +/** Normalize document alias text. */ function normalizeDocumentAliasText(value: string) { return value .replace(/([a-z])([A-Z])/g, "$1 $2") @@ -2650,6 +2717,7 @@ function normalizeDocumentAliasText(value: string) { .trim(); } +/** Has document alias without top title support. */ function hasDocumentAliasWithoutTopTitleSupport(query: string, results: SearchResult[]) { const aliases = analyzeClinicalQuery(query) .documentTitleTerms.map(normalizeDocumentAliasText) @@ -2663,11 +2731,13 @@ function hasDocumentAliasWithoutTopTitleSupport(query: string, results: SearchRe }); } +/** Has admission community lookup intent. */ function hasAdmissionCommunityLookupIntent(query: string) { const normalized = normalizeDocumentAliasText(query); return /\badmission\b/.test(normalized) && /\bcommunity\b/.test(normalized); } +/** Has admission community title support. */ function hasAdmissionCommunityTitleSupport(results: SearchResult[]) { return results.slice(0, 5).some((result) => { if (result.match_explanation?.titleHit || result.match_explanation?.labelHit) { @@ -2683,6 +2753,7 @@ function hasAdmissionCommunityTitleSupport(results: SearchResult[]) { }); } +/** Should return before memory. */ function shouldReturnBeforeMemory( queryClass: RagQueryClass, decision: { returnFastPath: boolean; reason: string | null }, @@ -2694,6 +2765,7 @@ function shouldReturnBeforeMemory( return !shouldUseMemoryBeforeFastPath(queryClass); } +/** Evidence text for gate. */ export function evidenceTextForGate(result: SearchResult) { const tableText = (result.table_facts ?? []) .map((fact) => @@ -2727,14 +2799,17 @@ export function evidenceTextForGate(result: SearchResult) { ).toLowerCase(); } +/** Top evidence text. */ function topEvidenceText(results: SearchResult[], limit = 5) { return results.slice(0, limit).map(evidenceTextForGate).join(" "); } +/** Has any term. */ function hasAnyTerm(text: string, pattern: RegExp) { return pattern.test(text); } +/** Is risk flowchart next step query. */ function isRiskFlowchartNextStepQuery(query: string) { return ( /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && @@ -2743,6 +2818,7 @@ function isRiskFlowchartNextStepQuery(query: string) { ); } +/** Has risk flowchart action evidence. */ function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], limit = 5) { // A single result must carry BOTH the zone context and the action language // (escalate / urgent review): scattering the two term groups across different @@ -2767,16 +2843,19 @@ function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], }); } +/** Has dose amount evidence for gate. */ function hasDoseAmountEvidenceForGate(result: SearchResult) { return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms)\b/i.test(evidenceTextForGate(result)); } +/** Has route evidence for gate. */ function hasRouteEvidenceForGate(result: SearchResult) { return /\b(?:oral|orally|intramuscular|intramuscularly|subcutaneous|subcutaneously|subcut|sublingual|sublingually|\bim\b|\bpo\b|\bsc\b|\bsl\b)\b/i.test( evidenceTextForGate(result), ); } +/** Has direct source image evidence. */ function hasDirectSourceImageEvidence(result: SearchResult) { const sourceImageIds = new Set( [result.index_unit?.source_image_id, ...(result.table_facts ?? []).map((fact) => fact.source_image_id)].filter( @@ -2791,6 +2870,7 @@ function hasDirectSourceImageEvidence(result: SearchResult) { ); } +/** Source image required for query. */ function sourceImageRequiredForQuery(query: string) { return ( /\b(?:show|display|attach|open|view|source|original)\b/i.test(query) && @@ -2798,6 +2878,7 @@ function sourceImageRequiredForQuery(query: string) { ); } +/** Direct title or alias support. */ function directTitleOrAliasSupport(query: string, results: SearchResult[]) { return ( hasDirectTitleSupport(query, results) || @@ -2805,6 +2886,7 @@ function directTitleOrAliasSupport(query: string, results: SearchResult[]) { ); } +/** Record retrieval selection telemetry. */ function recordRetrievalSelectionTelemetry( telemetry: SearchTelemetry, intent: RetrievalIntent, @@ -2814,6 +2896,7 @@ function recordRetrievalSelectionTelemetry( telemetry.retrieval_selection = summary; } +/** Select ranked retrieval results. */ function selectRankedRetrievalResults(args: { query: string; queryClass: RagQueryClass; @@ -2835,6 +2918,7 @@ function selectRankedRetrievalResults(args: { return selection.results; } +/** Evaluate evidence coverage gate. */ export function evaluateEvidenceCoverageGate( query: string, results: SearchResult[], @@ -3018,6 +3102,7 @@ export function evaluateEvidenceCoverageGate( }; } +/** Prepare coverage gate results. */ async function prepareCoverageGateResults(args: { supabase: ReturnType; query: string; @@ -3057,6 +3142,7 @@ async function prepareCoverageGateResults(args: { return results; } +/** Apply coverage gate telemetry. */ function applyCoverageGateTelemetry( telemetry: SearchTelemetry, gate: ReturnType, @@ -3073,6 +3159,7 @@ function applyCoverageGateTelemetry( } } +/** Mark embedding skipped by text fast path. */ function markEmbeddingSkippedByTextFastPath(telemetry: SearchTelemetry, reason: string | null) { telemetry.embedding_skipped = true; telemetry.embedding_skip_reason = reason ?? "text_fast_path"; @@ -3080,6 +3167,7 @@ function markEmbeddingSkippedByTextFastPath(telemetry: SearchTelemetry, reason: telemetry.vector_skipped_reason = reason ?? "text_fast_path"; } +/** Should attempt document lookup fast path. */ function shouldAttemptDocumentLookupFastPath(queryClass: RagQueryClass) { return ( queryClass === "document_lookup" || @@ -3089,10 +3177,12 @@ function shouldAttemptDocumentLookupFastPath(queryClass: RagQueryClass) { ); } +/** Should use memory before fast path. */ function shouldUseMemoryBeforeFastPath(queryClass: RagQueryClass) { return queryClass === "table_threshold" || queryClass === "medication_dose_risk" || queryClass === "comparison"; } +/** Memory card answer score. */ function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryClass: RagQueryClass) { const content = sourceTextForDisplay(card.content); if (!content) return -1; @@ -3136,6 +3226,7 @@ function memoryCardAnswerScore(card: DocumentMemoryCard, query: string, queryCla return tokenHits * 0.08 + typeBoost + doseBoost + (card.confidence ?? 0) * 0.08 + lowValueTitlePenalty; } +/** Rank memory cards for answer. */ export function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: string, queryClass: RagQueryClass) { return [...cards] .map((card, index) => ({ @@ -3148,6 +3239,7 @@ export function rankMemoryCardsForAnswer(cards: DocumentMemoryCard[], query: str .map((item) => item.card); } +/** Search chunks with telemetry. */ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { assertGlobalSearchAllowed(args); throwIfAborted(args.signal); @@ -3782,6 +3874,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { return { results, telemetry }; } +/** Build related documents safe. */ async function buildRelatedDocumentsSafe(args: { query: string; results: SearchResult[]; ownerId?: string }) { try { return await fetchRelatedDocuments({ @@ -3795,11 +3888,13 @@ async function buildRelatedDocumentsSafe(args: { query: string; results: SearchR } } +/** Search chunks. */ export async function searchChunks(args: SearchChunksArgs) { const { results } = await searchChunksWithTelemetry(args); return results; } +/** Parse answer json. */ export function parseAnswerJson(raw: string, results: SearchResult[], query?: string): RagAnswer { try { const parsed = answerJsonSchema.parse(JSON.parse(raw)); @@ -3844,6 +3939,7 @@ export function parseAnswerJson(raw: string, results: SearchResult[], query?: st } } +/** Annotate answer with diagnostics. */ function annotateAnswerWithDiagnostics( answer: T, diagnostics: RetrievalDiagnostics, @@ -3860,10 +3956,12 @@ function annotateAnswerWithDiagnostics( }; } +/** Answer question. */ export async function answerQuestion(query: string, documentId?: string) { return answerQuestionWithScope({ query, documentId, allowGlobalSearch: true }); } +/** Answer question with scope. */ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs): Promise { const startedAt = Date.now(); const coalescingEnabled = !args.skipCache && env.RAG_ANSWER_CACHE_TTL_MS > 0 && env.RAG_ANSWER_CACHE_SIZE > 0; @@ -3894,6 +3992,7 @@ export async function answerQuestionWithScope(args: AnswerQuestionWithScopeArgs) return pending; } +/** Answer question with scope uncoalesced. */ async function answerQuestionWithScopeUncoalesced( args: AnswerQuestionWithScopeArgs, startedAt: number, @@ -4426,6 +4525,7 @@ Threshold/decision question -> targeted lead sentence plus a couple of tight sec Return data matching the supplied structured output schema.`; + /** Build answer input. */ function buildAnswerInput(contextResults: SearchResult[]) { const sourceGuide = crossDocumentPlan.enabled ? buildCrossDocumentSourceGuide(contextResults) : ""; const fusedBrief = crossDocumentFusionBrief?.text ?? ""; @@ -4495,6 +4595,7 @@ ${buildRagSourceBlock(contextResults, { query: answerFocusQuery, queryClass })}` const contextPackOptions = { crossDocument: crossDocumentPlan.enabled }; const packedContextCache = new Map(); + /** Pack context for generation. */ async function packContextForGeneration(contextResults: SearchResult[]) { const cacheKey = packedContextCacheKey(contextResults, queryClass, { ...contextPackOptions, @@ -4513,6 +4614,7 @@ ${buildRagSourceBlock(contextResults, { query: answerFocusQuery, queryClass })}` return packed; } + /** Generate with model. */ async function generateWithModel( model: string, contextResults: SearchResult[], @@ -4565,15 +4667,18 @@ ${qualityRetryInstruction}` } } + /** Generation incomplete reason. */ function generationIncompleteReason(result: OpenAITextResult) { return result.incompleteReason ?? (result.status === "incomplete" ? "incomplete" : "unknown"); } + /** Generation retry reason. */ function generationRetryReason(prefix: string, result: OpenAITextResult) { const reason = generationIncompleteReason(result); return reason === "max_output_tokens" ? `${prefix}_max_output_tokens` : `${prefix}_incomplete_${reason}`; } + /** Should recover fast failure extractively. */ function shouldRecoverFastFailureExtractively(retryReason: string) { const sourceBackedRecoveryRetryReasons = new Set([ "fast_unsupported_retry_strong", @@ -4593,6 +4698,7 @@ ${qualityRetryInstruction}` ); } + /** Summarize generation failure reason. */ function summarizeGenerationFailureReason(error: unknown) { const message = (error instanceof Error ? error.message : typeof error === "string" ? error : "").trim(); const normalized = message.toLowerCase(); @@ -4610,6 +4716,7 @@ ${qualityRetryInstruction}` return "generation_failed"; } + /** Build generation fallback answer. */ async function buildGenerationFallbackAnswer( error: unknown, relatedDocuments: RelatedDocument[], @@ -5163,6 +5270,7 @@ ${qualityRetryInstruction}` } } +/** Summarize document. */ export async function summarizeDocument(documentId: string, ownerId?: string) { const supabase = createAdminClient(); let documentQuery = supabase.from("documents").select("id,title,file_name,metadata").eq("id", documentId); diff --git a/src/lib/registry-corpus.ts b/src/lib/registry-corpus.ts index 24ef5ecc2..cec9c29e3 100644 --- a/src/lib/registry-corpus.ts +++ b/src/lib/registry-corpus.ts @@ -49,10 +49,12 @@ export type RegistryCorpusEditTarget = const REGISTRY_EMBEDDING_WRITE_BATCH_SIZE = 64; +/** Sha256. */ function sha256(value: string) { return createHash("sha256").update(value).digest("hex"); } +/** Deterministic uuid. */ function deterministicUuid(seed: string) { const hex = sha256(seed).slice(0, 32).split(""); hex[12] = "5"; @@ -61,6 +63,7 @@ function deterministicUuid(seed: string) { return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`; } +/** Compact text. */ function compactText(parts: unknown[], limit = 8000) { const text = parts .flatMap((part) => (Array.isArray(part) ? part : [part])) @@ -74,6 +77,7 @@ function compactText(parts: unknown[], limit = 8000) { return text.length <= limit ? text : `${text.slice(0, limit - 3).trim()}...`; } +/** Registry base metadata. */ function registryBaseMetadata(entry: RegistryCorpusEntry): Record { return { source_kind: "registry_record", @@ -90,14 +94,17 @@ function registryBaseMetadata(entry: RegistryCorpusEntry): Record }; } +/** Registry document id. */ function registryDocumentId(entry: RegistryCorpusEntry) { return deterministicUuid(`registry-document:${entry.kind}:${entry.recordId}`); } +/** Registry entry metadata. */ function registryEntryMetadata(entry: RegistryCorpusEntry): Record { return { ...registryBaseMetadata(entry), ...entry.metadata }; } +/** Registry corpus identity. */ function registryCorpusIdentity(entry: RegistryCorpusEntry) { return { documentId: registryDocumentId(entry), @@ -105,6 +112,7 @@ function registryCorpusIdentity(entry: RegistryCorpusEntry) { }; } +/** Registry document row. */ function registryDocumentRow(entry: RegistryCorpusEntry): TablesInsert<"documents"> { const { documentId, metadata } = registryCorpusIdentity(entry); const detailHref = registryCorpusDetailHref({ @@ -135,6 +143,7 @@ function registryDocumentRow(entry: RegistryCorpusEntry): TablesInsert<"document }; } +/** Registry chunk row. */ function registryChunkRow(entry: RegistryCorpusEntry, embedding: Vector): TablesInsert<"document_chunks"> { const { documentId, metadata } = registryCorpusIdentity(entry); return { @@ -154,10 +163,12 @@ function registryChunkRow(entry: RegistryCorpusEntry, embedding: Vector): Tables }; } +/** Registry corpus embedding enabled. */ export function registryCorpusEmbeddingEnabled() { return env.RAG_REGISTRY_CORPUS_EMBEDDING === true; } +/** Clinical registry rows to corpus entries. */ export function clinicalRegistryRowsToCorpusEntries(rows: RegistryRecordRow[]): RegistryCorpusEntry[] { return rows.map((row) => { const kind: RegistryRecordKind = row.kind === "form" ? "form" : "service"; @@ -197,6 +208,7 @@ export function clinicalRegistryRowsToCorpusEntries(rows: RegistryRecordRow[]): }); } +/** Medication rows to corpus entries. */ export function medicationRowsToCorpusEntries(rows: MedicationRecordRow[]): RegistryCorpusEntry[] { return rows.map((row) => { const record = rowToMedicationRecord(row); @@ -245,6 +257,7 @@ export function medicationRowsToCorpusEntries(rows: MedicationRecordRow[]): Regi }); } +/** Differential rows to corpus entries. */ export function differentialRowsToCorpusEntries(rows: DifferentialRecordRow[]): RegistryCorpusEntry[] { return rows.map((row) => { const isPresentation = row.kind === "presentation"; @@ -279,6 +292,7 @@ export function differentialRowsToCorpusEntries(rows: DifferentialRecordRow[]): }); } +/** Embed registry corpus entries. */ export async function embedRegistryCorpusEntries(supabase: AdminClient, entries: RegistryCorpusEntry[]) { if (entries.length === 0) return { documentCount: 0, chunkCount: 0 }; @@ -309,13 +323,19 @@ export async function embedRegistryCorpusEntries(supabase: AdminClient, entries: const { error: chunkError } = await supabase.from("document_chunks").upsert(chunks, { onConflict: "id" }); if (chunkError) { const insertedDocumentIds = documentIds.filter((id) => !existingDocumentIds.has(id)); + const rollbackErrors: string[] = []; if (insertedDocumentIds.length > 0) { - await supabase.from("documents").delete().in("id", insertedDocumentIds); + const { error: deleteError } = await supabase.from("documents").delete().in("id", insertedDocumentIds); + if (deleteError) rollbackErrors.push(`delete failed: ${deleteError.message}`); } if (existingDocumentSnapshots.length > 0) { - await supabase.from("documents").upsert(existingDocumentSnapshots, { onConflict: "id" }); + const { error: restoreError } = await supabase + .from("documents") + .upsert(existingDocumentSnapshots, { onConflict: "id" }); + if (restoreError) rollbackErrors.push(`restore failed: ${restoreError.message}`); } - throw new Error(`Registry corpus chunk upsert failed: ${chunkError.message}`); + const suffix = rollbackErrors.length > 0 ? `; rollback errors: ${rollbackErrors.join(", ")}` : ""; + throw new Error(`Registry corpus chunk upsert failed: ${chunkError.message}${suffix}`); } documentCount += documents.length; @@ -325,14 +345,17 @@ export async function embedRegistryCorpusEntries(supabase: AdminClient, entries: return { documentCount, chunkCount }; } +/** Embed clinical registry rows. */ export function embedClinicalRegistryRows(supabase: AdminClient, rows: RegistryRecordRow[]) { return embedRegistryCorpusEntries(supabase, clinicalRegistryRowsToCorpusEntries(rows)); } +/** Embed medication rows. */ export function embedMedicationRows(supabase: AdminClient, rows: MedicationRecordRow[]) { return embedRegistryCorpusEntries(supabase, medicationRowsToCorpusEntries(rows)); } +/** Embed differential rows. */ export function embedDifferentialRows(supabase: AdminClient, rows: DifferentialRecordRow[]) { return embedRegistryCorpusEntries(supabase, differentialRowsToCorpusEntries(rows)); } @@ -352,10 +375,12 @@ export async function embedReloadedOwnerRows( return chunkCount; } +/** Skipped registry embed result. */ function skippedRegistryEmbedResult(reason: RegistryCorpusEmbedResult["reason"]): RegistryCorpusEmbedResult { return { documentCount: 0, chunkCount: 0, skipped: true, reason }; } +/** Reembed clinical registry record by slug. */ export async function reembedClinicalRegistryRecordBySlug( supabase: AdminClient, args: { ownerId: string; slug: string; kind?: RegistryRecordKind }, @@ -371,6 +396,7 @@ export async function reembedClinicalRegistryRecordBySlug( return embedClinicalRegistryRows(supabase, [data as RegistryRecordRow]); } +/** Reembed medication record by slug. */ export async function reembedMedicationRecordBySlug( supabase: AdminClient, args: { ownerId: string; slug: string }, @@ -389,6 +415,7 @@ export async function reembedMedicationRecordBySlug( return embedMedicationRows(supabase, [data as MedicationRecordRow]); } +/** Reembed differential record by slug. */ export async function reembedDifferentialRecordBySlug( supabase: AdminClient, args: { ownerId: string; slug: string; kind?: DifferentialRecordRow["kind"] }, @@ -404,6 +431,7 @@ export async function reembedDifferentialRecordBySlug( return embedDifferentialRows(supabase, [data as DifferentialRecordRow]); } +/** Reembed registry record after edit. */ export function reembedRegistryRecordAfterEdit( supabase: AdminClient, target: RegistryCorpusEditTarget, @@ -427,6 +455,7 @@ export function reembedRegistryRecordAfterEdit( }); } +/** Best effort reembed registry record after edit. */ export async function bestEffortReembedRegistryRecordAfterEdit(args: { supabase: AdminClient; target: RegistryCorpusEditTarget; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index ae7e65f53..7d39c5d85 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-09T08:52:11.900Z", + "generated_at": "2026-07-09T11:02:02.346Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", "schema_sha256": "3a60bf1561142aed58e45a1cf8a9605ccc715dd7358e154c4ad9879b82be4cd5", - "replay_seconds": 16, + "replay_seconds": 28, "snapshot": { "views": [ { diff --git a/tests/rag-routing.test.ts b/tests/rag-routing.test.ts index 86bdae88b..d36f8d7a5 100644 --- a/tests/rag-routing.test.ts +++ b/tests/rag-routing.test.ts @@ -194,6 +194,22 @@ describe("RAG answer routing", () => { expect(selected.reason).toBe("clinical_risk_or_complex_query"); }); + it("does not treat unrelated stop wording as clozapine blood withhold evidence", () => { + const selected = route("What FBC threshold should withhold clozapine?", [ + source({ + title: "Clozapine Prescribing and Monitoring", + file_name: "CG.MHSP.ClozapinePresAdminMonitor.pdf", + content: + "FBC monitoring is weekly for the first 18 weeks. Patients should stop smoking before starting treatment.", + text_rank: 0.14, + }), + ]); + + expect(selected.mode).toBe("strong"); + expect(selected.model).toBe("strong-model"); + expect(selected.reason).toBe("clinical_risk_or_complex_query"); + }); + it("keeps explicit table lookup questions extractive even when medication terms are present", () => { const selected = route("Which table covers agitation and arousal pharmacological management?", [ source({