From 1fe13f1e4f5c153b24f5a6633f283c45883867d5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:52:26 +0800 Subject: [PATCH 1/6] fix: audit P0 RAG cache, synopsis parity, and safety hardening --- scripts/production-readiness.ts | 5 ++ src/app/api/documents/[id]/route.ts | 25 ++++++--- src/components/ClinicalDashboard.tsx | 17 ++++-- src/lib/answer-ranking.ts | 3 +- src/lib/api-rate-limit.ts | 12 ++++- src/lib/clinical-evidence-haystack.ts | 28 ++++++++++ src/lib/clinical-search.ts | 21 ++------ src/lib/deep-memory.ts | 6 +-- src/lib/evidence-relevance.ts | 2 +- src/lib/rag-cache-utils.ts | 4 ++ src/lib/rag.ts | 76 ++++++++++++++++----------- tests/answer-ranking.test.ts | 20 +++++++ tests/clinical-search.test.ts | 21 ++++++++ tests/rag-cache-utils.test.ts | 21 ++++++++ worker/main.ts | 7 ++- 15 files changed, 202 insertions(+), 66 deletions(-) create mode 100644 src/lib/clinical-evidence-haystack.ts create mode 100644 src/lib/rag-cache-utils.ts create mode 100644 tests/rag-cache-utils.test.ts diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index c90d909d7f..a63b05d3af 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -188,6 +188,11 @@ async function main() { } } + const productionLike = process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production"; + if (productionLike && !envModule.env.RAG_QUERY_HASH_SECRET) { + result.failures.push("RAG_QUERY_HASH_SECRET is required in a production-like environment."); + } + if (placeholderLooksLikeExample(envModule.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "")) { result.warnings.push("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY looks like a placeholder."); } diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 4ec7010edf..e96ea792c6 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -506,12 +506,11 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i // job (status "pending") racing this DELETE let the worker upload a new // generation of image objects after the storage paths were enumerated, // orphaning them permanently. - const { data: activeJobs, error: activeJobsError } = await supabase - .from("ingestion_jobs") - .select("id,status") - .eq("document_id", id) - .in("status", ["pending", "processing"]) - .limit(1); + async function loadActiveJobs() { + return supabase.from("ingestion_jobs").select("id,status").eq("document_id", id).in("status", ["pending", "processing"]).limit(1); + } + + const { data: activeJobs, error: activeJobsError } = await loadActiveJobs(); if (activeJobsError) throw new Error(activeJobsError.message); if ((activeJobs ?? []).length > 0) { @@ -561,6 +560,20 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i throw new Error(ledgerWarning ? `${message}; ${ledgerWarning}` : message); } + const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs(); + if (lateActiveJobsError) throw new Error(lateActiveJobsError.message); + if ((lateActiveJobs ?? []).length > 0) { + const message = "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting."; + const ledgerWarning = await updateStorageCleanupJob({ + supabase, + cleanupJobId, + status: "failed", + storageRemoved: 0, + warnings: [message], + }); + throw new PublicApiError(ledgerWarning ? `${message}; ${ledgerWarning}` : message, 409); + } + const { error: deleteError } = await supabase.from("documents").delete().eq("id", id).eq("owner_id", user.id); if (deleteError) { const ledgerWarning = await updateStorageCleanupJob({ diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 018ec0c1d5..f584071938 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3062,17 +3062,26 @@ export function ClinicalDashboard({ window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); + const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId; + try { const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); const payload = await runWithRetries(() => requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode), ); - applySearchResult(payload); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + applySearchResult(payload); + } } catch (requestError) { - setError(requestError instanceof Error ? requestError.message : "Document search failed"); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + setError(requestError instanceof Error ? requestError.message : "Document search failed"); + } } finally { - setLoading(false); - setAnswerProgress(null); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + setLoading(false); + setAnswerProgress(null); + } } } diff --git a/src/lib/answer-ranking.ts b/src/lib/answer-ranking.ts index 4a3be402a2..9fe1d9e6b4 100644 --- a/src/lib/answer-ranking.ts +++ b/src/lib/answer-ranking.ts @@ -70,6 +70,7 @@ function resultTexts(result: SearchResult) { const metadataText = `${labels} ${result.document_summary ?? ""}`; const titleText = `${result.title} ${result.file_name}`; const sectionText = result.section_heading ?? ""; + const synopsisText = result.retrieval_synopsis ?? ""; const contentText = `${sourceTextForModel(result.content)} ${imageEvidenceText(result)}`; return { title: normalizeText(titleText), @@ -78,7 +79,7 @@ function resultTexts(result: SearchResult) { metadata: normalizeText(metadataText), adjacent: normalizeText(result.adjacent_context ?? ""), combined: normalizeText( - `${titleText} ${sectionText} ${contentText} ${metadataText} ${result.adjacent_context ?? ""}`, + `${titleText} ${sectionText} ${synopsisText} ${contentText} ${metadataText} ${result.adjacent_context ?? ""}`, ), }; } diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 842d99dc8f..cb6f15b05c 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -10,7 +10,16 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() { } export type ApiRateLimitBucket = - "answer" | "search" | "document_read" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; + | "answer" + | "search" + | "document_read" + | "document_upload" + | "document_summarize" + | "document_reindex" + | "bulk_reindex" + | "registry" + | "medications" + | "differentials"; export type ApiRateLimitResult = { limited: boolean; @@ -24,6 +33,7 @@ const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, document_read: { limit: 180, windowSeconds: 60 }, + document_upload: { limit: 12, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, diff --git a/src/lib/clinical-evidence-haystack.ts b/src/lib/clinical-evidence-haystack.ts new file mode 100644 index 0000000000..e02b63a7e2 --- /dev/null +++ b/src/lib/clinical-evidence-haystack.ts @@ -0,0 +1,28 @@ +import type { SearchResult } from "@/lib/types"; + +export function clinicalImageEvidenceHaystack(images: SearchResult["images"]) { + return (images ?? []) + .map((image) => + [ + image.tableTextSnippet, + image.accessibleTableMarkdown, + image.caption, + image.tableTitle, + image.tableLabel, + ] + .filter(Boolean) + .join(" "), + ) + .join(" "); +} + +export function clinicalResultEvidenceHaystack(result: SearchResult) { + const tableFactText = (result.table_facts ?? []) + .map( + (fact) => + `${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.clinical_parameter ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`, + ) + .join(" "); + const memoryCardText = (result.memory_cards ?? []).map((card) => `${card.title} ${card.content}`).join(" "); + return `${result.title} ${result.file_name} ${result.section_heading ?? ""} ${result.retrieval_synopsis ?? ""} ${result.content} ${tableFactText} ${memoryCardText} ${clinicalImageEvidenceHaystack(result.images)}`.toLowerCase(); +} diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index a6a0bd5a8a..25dca04bb4 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1,4 +1,5 @@ import { isClinicalImageEvidence } from "@/lib/image-filtering"; +import { clinicalResultEvidenceHaystack } from "@/lib/clinical-evidence-haystack"; import { expandClinicalVocabularyText } from "@/lib/clinical-vocabulary"; import { freshnessDecayPenalty, rankingConfig } from "@/lib/ranking-config"; import type { @@ -720,30 +721,14 @@ function evidenceDensityBoost(result: SearchResult, tokens: string[]) { } export function hasDoseEvidenceSupport(result: SearchResult) { - const haystack = `${result.section_heading ?? ""} ${result.content} ${(result.table_facts ?? []) - .map( - (fact) => - `${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.clinical_parameter ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`, - ) - .join(" ")} ${(result.memory_cards ?? []).map((card) => `${card.title} ${card.content}`).join(" ")} ${( - result.images ?? [] - ) - .map((image) => `${image.tableTextSnippet ?? ""} ${image.caption ?? ""} ${image.tableTitle ?? ""}`) - .join(" ")}`.toLowerCase(); + 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( haystack, ); } function hasMedicationDoseAmountEvidence(result: SearchResult) { - const haystack = `${result.section_heading ?? ""} ${result.content} ${(result.table_facts ?? []) - .map( - (fact) => - `${fact.table_title ?? ""} ${fact.row_label ?? ""} ${fact.clinical_parameter ?? ""} ${fact.threshold_value ?? ""} ${fact.action ?? ""}`, - ) - .join(" ")} ${(result.images ?? []) - .map((image) => `${image.tableTextSnippet ?? ""} ${image.caption ?? ""} ${image.tableTitle ?? ""}`) - .join(" ")}`.toLowerCase(); + const haystack = clinicalResultEvidenceHaystack(result); return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|micrograms)\b/i.test(haystack); } diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts index fe6d026220..c24b540261 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -699,11 +699,11 @@ export async function upsertDocumentDeepMemory(args: { // delete then insert without any intervening network dependency (M11). await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id); await args.supabase.from("document_sections").delete().eq("document_id", args.document.id); - await args.supabase + const { error: indexUnitDeleteError } = await args.supabase .from("document_index_units") .delete() - .eq("document_id", args.document.id) - .then(undefined, () => undefined); + .eq("document_id", args.document.id); + if (indexUnitDeleteError) throw new Error(indexUnitDeleteError.message); const { data: insertedSections, error: sectionError } = await args.supabase .from("document_sections") diff --git a/src/lib/evidence-relevance.ts b/src/lib/evidence-relevance.ts index 80f19d1a5a..0bddf94364 100644 --- a/src/lib/evidence-relevance.ts +++ b/src/lib/evidence-relevance.ts @@ -127,7 +127,7 @@ function labelsText(labels?: Array<{ label?: string | null; label_type?: string function sourceTextBlocks(source: SearchResult) { const title = normalizeSearchText( - `${source.title} ${source.file_name} ${source.section_heading ?? ""} ${(source.section_path ?? []).join(" ")}`, + `${source.title} ${source.file_name} ${source.section_heading ?? ""} ${(source.section_path ?? []).join(" ")} ${source.retrieval_synopsis ?? ""}`, ); const content = normalizeSearchText( [ diff --git a/src/lib/rag-cache-utils.ts b/src/lib/rag-cache-utils.ts new file mode 100644 index 0000000000..f113a7ee0a --- /dev/null +++ b/src/lib/rag-cache-utils.ts @@ -0,0 +1,4 @@ +/** Matches owner-scoped in-memory RAG cache keys (`rag-cache-v12|ownerId|...` and `ownerId|scope`). */ +export function ragCacheKeyMatchesOwner(key: string, ownerId: string) { + return key.includes(`|${ownerId}|`) || key.startsWith(`${ownerId}|`); +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index e43a125e6e..c8626ed6b8 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -34,6 +34,7 @@ import { import { env, isDemoMode, isLocalNoAuthMode, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; import { queryCacheKeyForStorage, queryPrivacyMetadata, queryTextForStorage } from "@/lib/query-privacy"; +import { ragCacheKeyMatchesOwner } from "@/lib/rag-cache-utils"; import { normalizeSourceMetadata } from "@/lib/source-metadata"; import { isReviewedTablePromotable } from "@/lib/table-review"; import { isClinicalImageEvidence, normalizeImageBbox } from "@/lib/image-filtering"; @@ -743,7 +744,7 @@ function deriveConfidence( if (acceptedCitations.length === 0 || results.length === 0) return "unsupported"; const citedIds = new Set(acceptedCitations.map((citation) => citation.chunk_id)); const citedResults = results.filter((result) => citedIds.has(result.id)); - const strongest = citedResults.reduce((max, result) => Math.max(max, result.similarity), 0); + const strongest = citedResults.reduce((max, result) => Math.max(max, scoreValue(result)), 0); if (strongest >= 0.82 && acceptedCitations.length >= 2) return "high"; if (strongest >= 0.64) return "medium"; return "low"; @@ -1133,9 +1134,9 @@ function fallbackReasonFromRouting(reason?: string | null) { ); } -const answerCache = new Map(); +const answerCache = new Map(); const answerInflight = new Map>(); -const searchCache = new Map(); +const searchCache = new Map(); const ragCacheDependencyVersion = "rag-cache-v12"; const cacheIndexingVersionTtlMs = 5000; const cacheIndexingVersionCache = new Map(); @@ -1389,10 +1390,10 @@ function cloneAnswer(answer: RagAnswer) { return structuredClone(answer); } -function getCachedAnswer( +async function getCachedAnswer( args: Pick, startedAt: number, -) { +): Promise { if (args.skipCache) return null; if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return null; @@ -1403,6 +1404,11 @@ function getCachedAnswer( answerCache.delete(key); return null; } + const indexingVersion = await cacheIndexingVersion(args); + if (cached.indexingVersion !== indexingVersion) { + answerCache.delete(key); + return null; + } const answer = cloneAnswer(cached.answer); answer.routingReason = answer.routingReason ? `${answer.routingReason}; answer_cache_hit` : "answer_cache_hit"; @@ -1413,17 +1419,19 @@ function getCachedAnswer( return answer; } -function setCachedAnswer( +async function setCachedAnswer( args: Pick, answer: RagAnswer, -) { +): Promise { if (args.skipCache) return; if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return; + const indexingVersion = await cacheIndexingVersion(args); const key = scopedAnswerCacheKey(args); answerCache.set(key, { expiresAt: Date.now() + env.RAG_ANSWER_CACHE_TTL_MS, answer: cloneAnswer(answer), + indexingVersion, }); while (answerCache.size > env.RAG_ANSWER_CACHE_SIZE) { @@ -1485,11 +1493,11 @@ function normalizeCacheStorageTelemetry(telemetry: SearchTelemetry): SearchTelem }; } -function getCachedSearch( +async function getCachedSearch( args: SearchChunksArgs, queryClass?: RagQueryClass, queryVariants: string[] = [], -): { results: SearchResult[]; telemetry: SearchTelemetry } | null { +): Promise<{ results: SearchResult[]; telemetry: SearchTelemetry } | null> { if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return null; const key = scopedSearchCacheKey(args, queryClass, queryVariants); @@ -1499,6 +1507,11 @@ function getCachedSearch( searchCache.delete(key); return null; } + const indexingVersion = await cacheIndexingVersion(args); + if (cached.indexingVersion !== indexingVersion) { + searchCache.delete(key); + return null; + } return { results: cloneSearchResults(cached.results), @@ -1517,20 +1530,22 @@ function getCachedSearch( }; } -function setCachedSearch( +async function setCachedSearch( args: SearchChunksArgs, results: SearchResult[], telemetry: SearchTelemetry, queryVariants: string[] = [], -) { +): Promise { if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return; const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry); + const indexingVersion = await cacheIndexingVersion(args); const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants); searchCache.set(key, { expiresAt: Date.now() + env.RAG_SEARCH_CACHE_TTL_MS, results: cloneSearchResults(results), telemetry: { ...cacheTelemetry }, + indexingVersion, }); while (searchCache.size > env.RAG_SEARCH_CACHE_SIZE) { @@ -1835,19 +1850,18 @@ export function invalidateRagCachesForOwner(ownerId?: string | null) { return; } - const prefix = `${ownerId}|`; const sharedCacheOwnerId = ownerId === "anonymous" ? null : ownerId; for (const key of answerCache.keys()) { - if (key.startsWith(prefix)) answerCache.delete(key); + if (ragCacheKeyMatchesOwner(key, ownerId)) answerCache.delete(key); } for (const key of answerInflight.keys()) { - if (key.startsWith(prefix) || key.includes(`|${ownerId}|`)) answerInflight.delete(key); + if (ragCacheKeyMatchesOwner(key, ownerId)) answerInflight.delete(key); } for (const key of searchCache.keys()) { - if (key.startsWith(prefix)) searchCache.delete(key); + if (ragCacheKeyMatchesOwner(key, ownerId)) searchCache.delete(key); } for (const key of cacheIndexingVersionCache.keys()) { - if (key.startsWith(prefix)) cacheIndexingVersionCache.delete(key); + if (ragCacheKeyMatchesOwner(key, ownerId)) cacheIndexingVersionCache.delete(key); } void (async () => { try { @@ -5461,11 +5475,11 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const queryVariants = buildRetrievalQueryVariants(retrievalQuery, queryAnalysis, ragAliases); telemetry.retrieval_query_variant_count = queryVariants.length; - const cached = getCachedSearch(args, queryClassification.queryClass, queryVariants); + const cached = await getCachedSearch(args, queryClassification.queryClass, queryVariants); if (cached) return cached; const sharedCached = await getSharedCachedSearch(args, queryClassification.queryClass, queryVariants); if (sharedCached?.kind === "hit") { - setCachedSearch(args, sharedCached.results, sharedCached.telemetry, queryVariants); + await setCachedSearch(args, sharedCached.results, sharedCached.telemetry, queryVariants); return { results: sharedCached.results, telemetry: sharedCached.telemetry }; } if (sharedCached?.kind === "miss") { @@ -5496,7 +5510,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.embedding_skip_reason = "unsupported_short_circuit"; telemetry.retrieval_strategy = "unsupported_short_circuit"; recordSearchScoreTelemetry(telemetry, []); - setCachedSearch(args, [], telemetry, queryVariants); + await setCachedSearch(args, [], telemetry, queryVariants); return { results: [] as SearchResult[], telemetry }; } @@ -5564,7 +5578,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { markEmbeddingSkippedByTextFastPath(telemetry, baseTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); - setCachedSearch(args, textFastResults, telemetry, queryVariants); + await setCachedSearch(args, textFastResults, telemetry, queryVariants); return { results: textFastResults, telemetry }; } @@ -5607,7 +5621,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { markEmbeddingSkippedByTextFastPath(telemetry, boostedTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); - setCachedSearch(args, textFastResults, telemetry, queryVariants); + await setCachedSearch(args, textFastResults, telemetry, queryVariants); return { results: textFastResults, telemetry }; } } @@ -5716,7 +5730,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { ); telemetry.retrieval_strategy = "document_lookup_fast_path"; recordSearchScoreTelemetry(telemetry, documentLookupResults); - setCachedSearch(args, documentLookupResults, telemetry, queryVariants); + await setCachedSearch(args, documentLookupResults, telemetry, queryVariants); return { results: documentLookupResults, telemetry }; } textFastResults = mergeSearchResults(documentLookupResults, textFastResults); @@ -5739,7 +5753,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); - setCachedSearch(args, coverageGateResults, telemetry, queryVariants); + await setCachedSearch(args, coverageGateResults, telemetry, queryVariants); return { results: coverageGateResults, telemetry }; } textFastResults = mergeSearchResults(coverageGateResults, textFastResults); @@ -5911,7 +5925,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; telemetry.retrieval_strategy = "hybrid"; recordSearchScoreTelemetry(telemetry, results); - setCachedSearch(args, results, telemetry, queryVariants); + await setCachedSearch(args, results, telemetry, queryVariants); return { results, telemetry }; } @@ -5991,7 +6005,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; telemetry.retrieval_strategy = "vector_fallback"; recordSearchScoreTelemetry(telemetry, results); - setCachedSearch(args, results, telemetry, queryVariants); + await setCachedSearch(args, results, telemetry, queryVariants); return { results, telemetry }; } @@ -6430,7 +6444,7 @@ async function answerQuestionWithScopeUncoalesced( // unchanged cache version) would bypass chooseAnswerRoute's refusal. Skipping the // cache lets the query flow to routing, which fails it closed to "unsupported". const adversarialQuery = hasAdversarialManipulationIntent(answerFocusQuery); - const cachedAnswer = adversarialQuery ? null : getCachedAnswer(args, startedAt); + const cachedAnswer = adversarialQuery ? null : await getCachedAnswer(args, startedAt); if (cachedAnswer) { const cachedSources = annotateSearchResults(answerFocusQuery, cachedAnswer.sources ?? []); const cachedRelevance = cachedAnswer.relevance ?? buildEvidenceRelevance(answerFocusQuery, cachedSources); @@ -6457,7 +6471,7 @@ async function answerQuestionWithScopeUncoalesced( } const sharedCachedAnswer = adversarialQuery ? null : await getSharedCachedAnswer(args, startedAt); if (sharedCachedAnswer) { - setCachedAnswer(args, sharedCachedAnswer); + await setCachedAnswer(args, sharedCachedAnswer); const cachedSources = annotateSearchResults(answerFocusQuery, sharedCachedAnswer.sources ?? []); const cachedRelevance = sharedCachedAnswer.relevance ?? buildEvidenceRelevance(answerFocusQuery, cachedSources); await args.onProgress?.({ @@ -6784,7 +6798,7 @@ async function answerQuestionWithScopeUncoalesced( }, }); - setCachedAnswer(args, finalizedAnswer); + await setCachedAnswer(args, finalizedAnswer); return finalizedAnswer; } @@ -6887,7 +6901,7 @@ async function answerQuestionWithScopeUncoalesced( }, }); - setCachedAnswer(args, finalizedAnswer); + await setCachedAnswer(args, finalizedAnswer); return finalizedAnswer; } @@ -7477,7 +7491,7 @@ ${qualityRetryInstruction}` }, }); - setCachedAnswer(args, answer); + await setCachedAnswer(args, answer); return answer; } catch (error) { const relatedDocuments = await relatedDocumentsPromise; @@ -7625,7 +7639,7 @@ ${qualityRetryInstruction}` }, }); - setCachedAnswer(args, fallbackAnswer); + await setCachedAnswer(args, fallbackAnswer); return fallbackAnswer; } } diff --git a/tests/answer-ranking.test.ts b/tests/answer-ranking.test.ts index 3166721653..78ccf69a9d 100644 --- a/tests/answer-ranking.test.ts +++ b/tests/answer-ranking.test.ts @@ -116,6 +116,26 @@ describe("answer evidence ranking", () => { ).toBeLessThan(0.45); }); + it("uses retrieval synopsis text in the combined evidence haystack", () => { + const ranking = rankAnswerEvidence("Summarize clozapine blood monitoring observations", [ + result({ + id: "generic-higher-score", + title: "General monitoring", + content: "Administrative review process only.", + hybrid_score: 0.72, + }), + result({ + id: "synopsis-match", + title: "Monitoring overview", + content: "Administrative review process only.", + retrieval_synopsis: "Clozapine blood monitoring observations and review timing guidance.", + hybrid_score: 0.56, + }), + ]); + + expect(ranking.rankedResults[0].id).toBe("synopsis-match"); + }); + it("does not let agitation title repetition outrank direct dosing evidence", () => { const ranking = rankAnswerEvidence("agitation and arousal dosing in psychiatric patients", [ result({ diff --git a/tests/clinical-search.test.ts b/tests/clinical-search.test.ts index 065e9662eb..337c511aa8 100644 --- a/tests/clinical-search.test.ts +++ b/tests/clinical-search.test.ts @@ -628,6 +628,27 @@ describe("clinical search query normalization", () => { expect(hasStructuredThresholdEvidence(tableResult)).toBe(true); }); + it("treats retrieval synopsis text as dose evidence support", () => { + const synopsisResult = result({ + title: "Medication chart", + content: "Administrative note only.", + retrieval_synopsis: "Lorazepam 1 mg IM route with repeat dose review guidance.", + }); + + expect(hasDoseEvidenceSupport(synopsisResult)).toBe(true); + expect( + rankClinicalResults("What dose and route are shown for lorazepam?", [ + result({ + id: "generic-higher-score", + title: "Medication overview", + content: "Administrative review note only.", + hybrid_score: 0.72, + }), + { ...synopsisResult, id: "synopsis-dose", hybrid_score: 0.58 }, + ])[0].id, + ).toBe("synopsis-dose"); + }); + it("detects structured threshold support from index units and table images", () => { expect( hasStructuredThresholdEvidence( diff --git a/tests/rag-cache-utils.test.ts b/tests/rag-cache-utils.test.ts new file mode 100644 index 0000000000..86232797d6 --- /dev/null +++ b/tests/rag-cache-utils.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { ragCacheKeyMatchesOwner } from "../src/lib/rag-cache-utils"; + +describe("ragCacheKeyMatchesOwner", () => { + const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + + it("matches versioned scoped cache keys", () => { + const key = `rag-cache-v12|${ownerId}|scope:all|plan:hybrid|class:dose`; + expect(ragCacheKeyMatchesOwner(key, ownerId)).toBe(true); + }); + + it("matches indexing-version cache keys", () => { + const key = `${ownerId}|scope:all`; + expect(ragCacheKeyMatchesOwner(key, ownerId)).toBe(true); + }); + + it("does not match a different owner", () => { + const key = `rag-cache-v12|bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb|scope:all`; + expect(ragCacheKeyMatchesOwner(key, ownerId)).toBe(false); + }); +}); diff --git a/worker/main.ts b/worker/main.ts index 64546daeb0..b540d7711d 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -35,6 +35,7 @@ import { assessDocumentIndexQuality } from "../src/lib/index-quality"; import { classifyAndCaptionImageFromBase64, embedTexts } from "../src/lib/openai"; import { safeErrorLogDetails, safeIngestionJobLog, redactCaptionIdentifiers } from "../src/lib/privacy"; import { isAtomicReindexCandidate } from "../src/lib/reindex-pipeline"; +import { invalidateRagCachesForDocumentMutation } from "../src/lib/rag"; import { createAdminClient } from "../src/lib/supabase/admin"; import { probeSupabaseHealth } from "../src/lib/supabase/health"; import type { Json, TablesInsert, TablesUpdate } from "../src/lib/supabase/database.types"; @@ -214,7 +215,10 @@ async function completeJob(job: JobRow, stage: string) { p_batch_id: job.batch_id ?? undefined, p_stage: stage, }); - if (!error) return; + if (!error) { + invalidateRagCachesForDocumentMutation(job.documents.owner_id ?? "anonymous"); + return; + } if (!isMissingSchemaError(error)) throw supabaseStageError("complete ingestion job", error); await updateJob(job.id, { @@ -227,6 +231,7 @@ async function completeJob(job: JobRow, stage: string) { }); await markSupersededSiblingJobs(job); await updateBatch(job.batch_id); + invalidateRagCachesForDocumentMutation(job.documents.owner_id ?? "anonymous"); } async function completeStrictEnrichmentJob(job: JobRow) { From 1864748de2611b3fb14ca9d11dccbc19002bbbbf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:55:08 +0800 Subject: [PATCH 2/6] fix: audit P2/P3 M13 guard, upload rate limit, and disposition docs --- docs/process-hardening.md | 7 + package.json | 1 + scripts/check-m13-migration.ts | 54 ++++++ src/app/api/upload/route.ts | 19 ++ src/lib/api-rate-limit.ts | 5 +- ...6010000_search_schema_health_m13_guard.sql | 183 ++++++++++++++++++ supabase/schema.sql | 17 ++ tests/supabase-schema.test.ts | 14 ++ worker/main.ts | 3 + 9 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 scripts/check-m13-migration.ts create mode 100644 supabase/migrations/20260706010000_search_schema_health_m13_guard.sql diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 48c530e9b6..7407a6c4b4 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -145,3 +145,10 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **Why (measured):** PR #118 caught a main-side change (uncapped candidate score + blanket source-governance metadata weighting in `retrieval-selection.ts`) that regressed the golden set 23/23 → 16/23 (doc-recall@5 1.0 → 0.76) on the partially-enriched corpus. `verify:cheap` was green throughout — only the golden retrieval eval surfaced it. Unit tests do not exercise live ranking, so they cannot substitute. - **Standing constraint (do not relearn):** source-governance metadata (`document_status`/`clinical_validation_status`/`extraction_quality`) must NOT weight retrieval **selection ordering**, and candidate relevance scores must stay clamped. Live scores saturate at 1.0 and the corpus is only partially enriched (unenriched → unknown/unverified), so metadata weighting buries correct documents. Governance belongs in ranking penalties and the answer/source-governance layer. See [[no-governance-weighting-in-retrieval-selection]] and `docs/rag-hybrid-findings-and-todo.md` (RC8). - **Answer-generation changes** (synthesis prompt, post-processing) additionally run `eval:rag --limit 15` + `eval:quality --rag-only` (grounded-supported must not drop; citation-failure 0). A new opt-in `npm run eval:answer-quality` reports a structural per-intent **targeting** metric (informational) for measuring how precisely answers hit the asked question. + +## Audit P2/P3 follow-up (2026-07-06) + +- **P0 carry-over (PR #278 / `cursor/audit-p2-p3-hardening-b54f`):** RAG cache owner/indexing-version guards, synopsis parity in ranking/detectors, DELETE TOCTOU re-check, worker cache invalidation on job completion, and `RAG_QUERY_HASH_SECRET` required in production-like readiness checks. +- **P2 M13:** `20260702000000_commit_generation_preserve_legacy_artifacts.sql` must be applied to live Supabase before reindex commits can safely purge legacy NULL-generation rows. After apply, run `npm run check:m13-migration`, `npm run reindex:health`, and `npm run check:indexing`. `search_schema_health()` now reports `commit_document_index_generation.preserve_legacy_artifacts_migration` when the live function body is stale. +- **P2 upload hardening:** `/api/upload` consumes the `document_upload` rate-limit bucket (12/min owner, 3/min anonymous). +- **P3 dispositioned (no code change):** L9 searchable-only `image_count` (documented in `worker/main.ts`); L11 triple `readFile` peak-memory trade-off (documented at the ingestion site); L18 duplicate `audit_logs` policy in an already-applied migration (do not edit applied migrations — consolidate only if migrations are ever squashed); L19 CSP `script-src 'unsafe-inline'` deferred (no active XSS sink today; nonce migration needs dedicated UI verification). diff --git a/package.json b/package.json index aaaeefd4aa..b0ce54be22 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "backfill:text-normalization": "tsx scripts/backfill-text-normalization.ts", "check:supabase-project": "tsx scripts/check-supabase-project.ts", "check:indexing": "tsx scripts/check-indexing.ts", + "check:m13-migration": "tsx scripts/check-m13-migration.ts", "check:type-scale": "node scripts/check-type-scale.mjs", "recover:ingestion": "tsx scripts/recover-ingestion-queue.ts", "registry:seed": "tsx scripts/seed-registry-records.ts", diff --git a/scripts/check-m13-migration.ts b/scripts/check-m13-migration.ts new file mode 100644 index 0000000000..741e3ea2b3 --- /dev/null +++ b/scripts/check-m13-migration.ts @@ -0,0 +1,54 @@ +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"; +const M13_MIGRATION = "20260702000000_commit_generation_preserve_legacy_artifacts.sql"; + +type SchemaHealth = { + ok?: boolean; + missing?: unknown; +}; + +function missingMarkers(data: unknown) { + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + const missing = (data as SchemaHealth).missing; + return Array.isArray(missing) ? missing.map(String) : []; +} + +async function main() { + const supabase = createAdminClient(); + const { data, error } = await supabase.rpc("search_schema_health"); + if (error) { + console.error("[M13 Migration] FAIL: search_schema_health unavailable:", error.message); + process.exit(1); + } + + const missing = missingMarkers(data); + if (missing.includes(M13_HEALTH_MARKER)) { + console.error( + `[M13 Migration] FAIL: live commit_document_index_generation is missing the preserve-legacy-artifacts guard.`, + ); + console.error(`Apply ${M13_MIGRATION} via the normal Supabase migration workflow, then run:`); + console.error(" npm run reindex:health"); + console.error(" npm run check:indexing"); + process.exit(1); + } + + if (missing.includes("commit_document_index_generation.signature")) { + console.error("[M13 Migration] FAIL: commit_document_index_generation RPC is missing on the live project."); + process.exit(1); + } + + console.log("[M13 Migration] PASS: commit generation preserve-legacy guard is live."); + if (missing.length > 0) { + console.log("[M13 Migration] Note: search_schema_health reported other missing items:", missing.join(", ")); + } +} + +main().catch((error) => { + console.error("[M13 Migration] FAIL:", error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index a2df002f95..410715ef5c 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -6,6 +6,11 @@ import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -90,6 +95,20 @@ export async function POST(request: Request) { if (!uploadOwnerId) { return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 }); } + + const rateLimit = await consumeSubjectApiRateLimit({ + supabase: adminSupabase, + subject: access.rateLimitSubject, + bucket: "document_upload", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse( + "Document upload is temporarily rate limited because too many requests were received. Retry shortly.", + rateLimit, + ); + } + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index cb6f15b05c..593cd9cfbf 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -17,9 +17,7 @@ export type ApiRateLimitBucket = | "document_summarize" | "document_reindex" | "bulk_reindex" - | "registry" - | "medications" - | "differentials"; + | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -44,6 +42,7 @@ const anonymousApiRateLimitDefaults: Partial; diff --git a/supabase/migrations/20260706010000_search_schema_health_m13_guard.sql b/supabase/migrations/20260706010000_search_schema_health_m13_guard.sql new file mode 100644 index 0000000000..e68bcb3c78 --- /dev/null +++ b/supabase/migrations/20260706010000_search_schema_health_m13_guard.sql @@ -0,0 +1,183 @@ +-- Audit P2 M13: teach search_schema_health() to detect when the live DB is still +-- running the pre-20260702 commit_document_index_generation body that deleted +-- legacy NULL-generation artifacts unconditionally. + +set search_path = public, extensions, pg_catalog; + +create or replace function public.search_schema_health() +returns jsonb +language plpgsql +stable +security definer +set search_path = public, extensions, pg_catalog, pg_temp +as $$ +declare + missing text[] := array[]::text[]; + vector_type_oid oid; + vector_schema text; + index_name text; + legacy_ivfflat_indexes text[]; + zero_vec extensions.vector(1536); + probe_text text := 'schema health probe zzznomatch'; + hybrid_rpcs text[] := array[ + 'match_document_chunks_hybrid', + 'match_document_index_units_hybrid', + 'match_document_embedding_fields_hybrid', + 'match_document_memory_cards_hybrid' + ]; + rpc_name text; + commit_fn_def text; + required_indexes constant text[] := array[ + 'documents_title_trgm_idx', + 'document_chunks_content_trgm_idx', + 'document_labels_label_trgm_idx', + 'document_summaries_summary_trgm_idx', + 'document_chunks_embedding_hnsw_idx', + 'document_embedding_fields_embedding_hnsw_idx', + 'document_memory_cards_embedding_hnsw_idx', + 'documents_indexed_owner_title_idx', + 'document_table_facts_owner_document_page_idx', + 'document_embedding_fields_owner_chunk_idx', + 'document_index_units_owner_chunk_type_idx', + 'document_table_facts_source_image_idx', + 'document_pages_document_idx', + 'document_sections_document_idx', + 'document_chunks_document_idx', + 'document_memory_cards_document_idx', + 'document_embedding_fields_document_idx', + 'document_table_facts_document_idx', + 'document_index_units_document_idx', + 'rag_retrieval_logs_owner_created_idx', + 'rag_retrieval_logs_miss_idx', + 'rag_retrieval_logs_strategy_idx' + ]; + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); +begin + select t.oid, n.nspname + into vector_type_oid, vector_schema + from pg_type t + join pg_namespace n on n.oid = t.typnamespace + where t.typname = 'vector' + and n.nspname = 'extensions' + limit 1; + + if vector_type_oid is null then + missing := array_append(missing, 'extensions.vector_type'); + end if; + + if to_regprocedure('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)') is null then + missing := array_append(missing, 'match_document_chunks.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)') is null then + missing := array_append(missing, 'match_document_lookup_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid_v2(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid_v2.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_index_units_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_embedding_fields_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_documents_for_query(text, integer, uuid)') is null then + missing := array_append(missing, 'match_documents_for_query.signature'); + end if; + if to_regprocedure('public.match_document_table_facts_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_table_facts_text.signature'); + end if; + if to_regprocedure('public.explain_retrieval_rpc(text, text, integer, uuid, uuid[], boolean)') is null then + missing := array_append(missing, 'explain_retrieval_rpc.signature'); + end if; + if to_regclass('public.rag_retrieval_logs') is null then + missing := array_append(missing, 'rag_retrieval_logs.table'); + end if; + + foreach index_name in array required_indexes loop + if not exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relname = index_name + and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) + ) then + missing := array_append(missing, index_name); + end if; + end loop; + + if vector_type_oid is not null then + zero_vec := (select ('[' || string_agg('0', ',') || ']') from generate_series(1, 1536))::extensions.vector(1536); + foreach rpc_name in array hybrid_rpcs loop + begin + execute format( + 'select 1 from public.%I($1, $2, 1, 0.1, null::uuid[], null::uuid) limit 1', + rpc_name + ) using zero_vec, probe_text; + exception + when undefined_function then + missing := array_append(missing, rpc_name || '.execution_signature'); + when others then + missing := array_append(missing, rpc_name || '.execution:' || SQLSTATE); + end; + end loop; + end if; + + commit_fn_def := pg_get_functiondef( + to_regprocedure( + 'public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb)' + ) + ); + if commit_fn_def is null then + missing := array_append(missing, 'commit_document_index_generation.signature'); + elsif position('from public.document_chunks replacement' in commit_fn_def) = 0 then + missing := array_append( + missing, + 'commit_document_index_generation.preserve_legacy_artifacts_migration' + ); + end if; + + select public.detect_legacy_ivfflat_indexes() into legacy_ivfflat_indexes; + + return jsonb_build_object( + 'ok', cardinality(missing) = 0, + 'missing', missing, + 'vector_extension_schema', vector_schema, + 'legacy_ivfflat_indexes', coalesce(legacy_ivfflat_indexes, array[]::text[]), + 'deferred_hnsw_indexes', array[]::text[], + 'checked_at', now() + ); +end; +$$; + +revoke execute on function public.search_schema_health() from public, anon, authenticated; +grant execute on function public.search_schema_health() to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 16ce2230c9..fc287b72c4 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -2372,6 +2372,7 @@ declare 'match_document_memory_cards_hybrid' ]; rpc_name text; + commit_fn_def text; required_indexes constant text[] := array[ 'documents_title_trgm_idx', 'document_chunks_content_trgm_idx', @@ -2503,6 +2504,22 @@ begin end loop; end if; + -- Audit M13: live DB must include the preserve-legacy-artifacts guard from + -- 20260702000000_commit_generation_preserve_legacy_artifacts.sql. + commit_fn_def := pg_get_functiondef( + to_regprocedure( + 'public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb)' + ) + ); + if commit_fn_def is null then + missing := array_append(missing, 'commit_document_index_generation.signature'); + elsif position('from public.document_chunks replacement' in commit_fn_def) = 0 then + missing := array_append( + missing, + 'commit_document_index_generation.preserve_legacy_artifacts_migration' + ); + end if; + select public.detect_legacy_ivfflat_indexes() into legacy_ivfflat_indexes; return jsonb_build_object( diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 6dd16d6c92..32a4318301 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -84,6 +84,10 @@ const searchHealthIndexesMigration = readFileSync( new URL("../supabase/migrations/20260705180000_reconcile_search_health_indexes.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const searchSchemaHealthM13GuardMigration = readFileSync( + new URL("../supabase/migrations/20260706010000_search_schema_health_m13_guard.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const ragQueriesRetentionMigration = readFileSync( new URL("../supabase/migrations/20260629060603_rag_queries_retention.sql", import.meta.url), "utf8", @@ -754,6 +758,16 @@ describe("Supabase schema Data API grants", () => { expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object("); expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)"); }); + + it("surfaces stale commit generation RPCs through search_schema_health", () => { + for (const sql of [schema, searchSchemaHealthM13GuardMigration]) { + expect(sql).toContain("commit_fn_def := pg_get_functiondef("); + expect(sql).toContain( + "commit_document_index_generation.preserve_legacy_artifacts_migration", + ); + expect(sql).toContain("from public.document_chunks replacement"); + } + }); }); describe("RC9 — lexical text path must not fabricate a cosine similarity", () => { diff --git a/worker/main.ts b/worker/main.ts index b540d7711d..8074d38ca5 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -438,6 +438,9 @@ async function commitDocumentIndexGeneration(args: { pages: ReturnType; quality: ReturnType; }) { + // Audit L9: p_image_count is searchable-only (insertedImages excludes + // audit-retained non-searchable rows). Retrieval filters searchable=true, so + // the persisted count intentionally differs from extracted_image_count. const { error } = await supabase.rpc("commit_document_index_generation", { p_document_id: args.documentId, p_index_generation_id: args.indexGenerationId, From a7133f2705b1093888621802b1efd39b877e0353 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:04:41 +0800 Subject: [PATCH 3/6] style: fix Prettier drift on audit P2/P3 files --- src/app/api/documents/[id]/route.ts | 10 ++++++++-- src/lib/clinical-evidence-haystack.ts | 8 +------- src/lib/rag.ts | 5 ++++- tests/supabase-schema.test.ts | 4 +--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index e96ea792c6..46da6c0b3c 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -507,7 +507,12 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i // generation of image objects after the storage paths were enumerated, // orphaning them permanently. async function loadActiveJobs() { - return supabase.from("ingestion_jobs").select("id,status").eq("document_id", id).in("status", ["pending", "processing"]).limit(1); + return supabase + .from("ingestion_jobs") + .select("id,status") + .eq("document_id", id) + .in("status", ["pending", "processing"]) + .limit(1); } const { data: activeJobs, error: activeJobsError } = await loadActiveJobs(); @@ -563,7 +568,8 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs(); if (lateActiveJobsError) throw new Error(lateActiveJobsError.message); if ((lateActiveJobs ?? []).length > 0) { - const message = "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting."; + const message = + "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting."; const ledgerWarning = await updateStorageCleanupJob({ supabase, cleanupJobId, diff --git a/src/lib/clinical-evidence-haystack.ts b/src/lib/clinical-evidence-haystack.ts index e02b63a7e2..5028799caa 100644 --- a/src/lib/clinical-evidence-haystack.ts +++ b/src/lib/clinical-evidence-haystack.ts @@ -3,13 +3,7 @@ import type { SearchResult } from "@/lib/types"; export function clinicalImageEvidenceHaystack(images: SearchResult["images"]) { return (images ?? []) .map((image) => - [ - image.tableTextSnippet, - image.accessibleTableMarkdown, - image.caption, - image.tableTitle, - image.tableLabel, - ] + [image.tableTextSnippet, image.accessibleTableMarkdown, image.caption, image.tableTitle, image.tableLabel] .filter(Boolean) .join(" "), ) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index c8626ed6b8..39199d9cf3 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1136,7 +1136,10 @@ function fallbackReasonFromRouting(reason?: string | null) { const answerCache = new Map(); const answerInflight = new Map>(); -const searchCache = new Map(); +const searchCache = new Map< + string, + { expiresAt: number; results: SearchResult[]; telemetry: SearchTelemetry; indexingVersion: string } +>(); const ragCacheDependencyVersion = "rag-cache-v12"; const cacheIndexingVersionTtlMs = 5000; const cacheIndexingVersionCache = new Map(); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 32a4318301..3469753993 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -762,9 +762,7 @@ describe("Supabase schema Data API grants", () => { it("surfaces stale commit generation RPCs through search_schema_health", () => { for (const sql of [schema, searchSchemaHealthM13GuardMigration]) { expect(sql).toContain("commit_fn_def := pg_get_functiondef("); - expect(sql).toContain( - "commit_document_index_generation.preserve_legacy_artifacts_migration", - ); + expect(sql).toContain("commit_document_index_generation.preserve_legacy_artifacts_migration"); expect(sql).toContain("from public.document_chunks replacement"); } }); From 2505afd48327aa4008eaee676c2fd28ddc6d338f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 5 Jul 2026 18:07:57 +0000 Subject: [PATCH 4/6] fix: guard stale inflight cache writes, synopsis numeric exemption, delete ordering - Skip setCachedAnswer when indexing version changed during inflight retrieval - Include retrieval_synopsis in hasNumericOrTableEvidence haystack - Run late active-job check before destructive trace/cache cleanup on delete - Delete document_index_units before memory cards/sections to avoid partial wipe --- src/app/api/documents/[id]/route.ts | 22 +++++++++++----------- src/lib/clinical-search.ts | 8 ++++---- src/lib/deep-memory.ts | 4 ++-- src/lib/rag.ts | 18 +++++++++++++----- tests/clinical-search.test.ts | 1 + 5 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 46da6c0b3c..0f050ddf26 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -551,10 +551,11 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i imagePaths, }); - try { - await deleteDocumentIndexTraceRows({ supabase, ownerId: user.id, documentId: id, chunkIds }); - } catch (traceCleanupError) { - const message = traceCleanupError instanceof Error ? traceCleanupError.message : "Index trace cleanup failed."; + const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs(); + if (lateActiveJobsError) throw new Error(lateActiveJobsError.message); + if ((lateActiveJobs ?? []).length > 0) { + const message = + "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting."; const ledgerWarning = await updateStorageCleanupJob({ supabase, cleanupJobId, @@ -562,14 +563,13 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i storageRemoved: 0, warnings: [message], }); - throw new Error(ledgerWarning ? `${message}; ${ledgerWarning}` : message); + throw new PublicApiError(ledgerWarning ? `${message}; ${ledgerWarning}` : message, 409); } - const { data: lateActiveJobs, error: lateActiveJobsError } = await loadActiveJobs(); - if (lateActiveJobsError) throw new Error(lateActiveJobsError.message); - if ((lateActiveJobs ?? []).length > 0) { - const message = - "Document gained pending or processing indexing work during delete. Stop or wait for the worker before deleting."; + try { + await deleteDocumentIndexTraceRows({ supabase, ownerId: user.id, documentId: id, chunkIds }); + } catch (traceCleanupError) { + const message = traceCleanupError instanceof Error ? traceCleanupError.message : "Index trace cleanup failed."; const ledgerWarning = await updateStorageCleanupJob({ supabase, cleanupJobId, @@ -577,7 +577,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i storageRemoved: 0, warnings: [message], }); - throw new PublicApiError(ledgerWarning ? `${message}; ${ledgerWarning}` : message, 409); + throw new Error(ledgerWarning ? `${message}; ${ledgerWarning}` : message); } const { error: deleteError } = await supabase.from("documents").delete().eq("id", id).eq("owner_id", user.id); diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 25dca04bb4..a1e983d686 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -747,13 +747,13 @@ export function hasNumericOrTableEvidence(result: SearchResult) { ) { return true; } - const content = `${result.section_heading ?? ""} ${result.content}`; + const haystack = clinicalResultEvidenceHaystack(result); // number + clinical unit, or an explicit threshold/range token. - return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|g|ml|mmol|mol|units?|%|x10\^?9|\/l|cells?)\b/i.test(content) + return /\b\d+(?:\.\d+)?\s?(?:mg|mcg|microgram|g|ml|mmol|mol|units?|%|x10\^?9|\/l|cells?)\b/i.test(haystack) ? true - : /\b\d/.test(content) && + : /\b\d/.test(haystack) && /\b(?:threshold|cut[\s-]?off|withhold|cease|range|level|anc|wbc|fbc|neutrophil|titrat|maximum|max\b)/i.test( - content, + haystack, ); } diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts index c24b540261..75b993148f 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -697,13 +697,13 @@ export async function upsertDocumentDeepMemory(args: { // All embeddings are in hand — replace the previous memory atomically-ish: // delete then insert without any intervening network dependency (M11). - await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id); - await args.supabase.from("document_sections").delete().eq("document_id", args.document.id); const { error: indexUnitDeleteError } = await args.supabase .from("document_index_units") .delete() .eq("document_id", args.document.id); if (indexUnitDeleteError) throw new Error(indexUnitDeleteError.message); + await args.supabase.from("document_memory_cards").delete().eq("document_id", args.document.id); + await args.supabase.from("document_sections").delete().eq("document_id", args.document.id); const { data: insertedSections, error: sectionError } = await args.supabase .from("document_sections") diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 39199d9cf3..81a45cd962 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1425,10 +1425,16 @@ async function getCachedAnswer( async function setCachedAnswer( args: Pick, answer: RagAnswer, + options?: { indexingVersionAtRetrievalStart?: string | null }, ): Promise { if (args.skipCache) return; if (env.RAG_ANSWER_CACHE_TTL_MS <= 0 || env.RAG_ANSWER_CACHE_SIZE <= 0) return; + if (options?.indexingVersionAtRetrievalStart) { + const currentIndexingVersion = await cacheIndexingVersion(args); + if (currentIndexingVersion !== options.indexingVersionAtRetrievalStart) return; + } + const indexingVersion = await cacheIndexingVersion(args); const key = scopedAnswerCacheKey(args); answerCache.set(key, { @@ -6447,6 +6453,8 @@ async function answerQuestionWithScopeUncoalesced( // unchanged cache version) would bypass chooseAnswerRoute's refusal. Skipping the // cache lets the query flow to routing, which fails it closed to "unsupported". const adversarialQuery = hasAdversarialManipulationIntent(answerFocusQuery); + const indexingVersionAtRetrievalStart = + adversarialQuery || args.skipCache ? null : await cacheIndexingVersion(args); const cachedAnswer = adversarialQuery ? null : await getCachedAnswer(args, startedAt); if (cachedAnswer) { const cachedSources = annotateSearchResults(answerFocusQuery, cachedAnswer.sources ?? []); @@ -6474,7 +6482,7 @@ async function answerQuestionWithScopeUncoalesced( } const sharedCachedAnswer = adversarialQuery ? null : await getSharedCachedAnswer(args, startedAt); if (sharedCachedAnswer) { - await setCachedAnswer(args, sharedCachedAnswer); + await setCachedAnswer(args, sharedCachedAnswer, { indexingVersionAtRetrievalStart }); const cachedSources = annotateSearchResults(answerFocusQuery, sharedCachedAnswer.sources ?? []); const cachedRelevance = sharedCachedAnswer.relevance ?? buildEvidenceRelevance(answerFocusQuery, cachedSources); await args.onProgress?.({ @@ -6801,7 +6809,7 @@ async function answerQuestionWithScopeUncoalesced( }, }); - await setCachedAnswer(args, finalizedAnswer); + await setCachedAnswer(args, finalizedAnswer, { indexingVersionAtRetrievalStart }); return finalizedAnswer; } @@ -6904,7 +6912,7 @@ async function answerQuestionWithScopeUncoalesced( }, }); - await setCachedAnswer(args, finalizedAnswer); + await setCachedAnswer(args, finalizedAnswer, { indexingVersionAtRetrievalStart }); return finalizedAnswer; } @@ -7494,7 +7502,7 @@ ${qualityRetryInstruction}` }, }); - await setCachedAnswer(args, answer); + await setCachedAnswer(args, answer, { indexingVersionAtRetrievalStart }); return answer; } catch (error) { const relatedDocuments = await relatedDocumentsPromise; @@ -7642,7 +7650,7 @@ ${qualityRetryInstruction}` }, }); - await setCachedAnswer(args, fallbackAnswer); + await setCachedAnswer(args, fallbackAnswer, { indexingVersionAtRetrievalStart }); return fallbackAnswer; } } diff --git a/tests/clinical-search.test.ts b/tests/clinical-search.test.ts index 337c511aa8..2c825f38f3 100644 --- a/tests/clinical-search.test.ts +++ b/tests/clinical-search.test.ts @@ -636,6 +636,7 @@ describe("clinical search query normalization", () => { }); expect(hasDoseEvidenceSupport(synopsisResult)).toBe(true); + expect(hasNumericOrTableEvidence(synopsisResult)).toBe(true); expect( rankClinicalResults("What dose and route are shown for lorazepam?", [ result({ From 74149600a18e72a7db28fae4aea64f10d1c0ad5a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:41:56 +0800 Subject: [PATCH 5/6] style: format rag.ts for CI prettier check Co-authored-by: Cursor --- src/lib/rag.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 81a45cd962..4103be40c7 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -6453,8 +6453,7 @@ async function answerQuestionWithScopeUncoalesced( // unchanged cache version) would bypass chooseAnswerRoute's refusal. Skipping the // cache lets the query flow to routing, which fails it closed to "unsupported". const adversarialQuery = hasAdversarialManipulationIntent(answerFocusQuery); - const indexingVersionAtRetrievalStart = - adversarialQuery || args.skipCache ? null : await cacheIndexingVersion(args); + const indexingVersionAtRetrievalStart = adversarialQuery || args.skipCache ? null : await cacheIndexingVersion(args); const cachedAnswer = adversarialQuery ? null : await getCachedAnswer(args, startedAt); if (cachedAnswer) { const cachedSources = annotateSearchResults(answerFocusQuery, cachedAnswer.sources ?? []); From 560c32e6eb3f41e7534bb6eeb0817cfd486b7d52 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:00:35 +0800 Subject: [PATCH 6/6] fix: use inline search request seq pattern in document search shortcut Co-authored-by: Cursor --- src/components/ClinicalDashboard.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index f584071938..e40718c5ee 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3062,23 +3062,22 @@ export function ClinicalDashboard({ window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); - const requestId = invalidateSearchRequests(searchRequestSeqRef.current); - searchRequestSeqRef.current = requestId; + const requestId = ++searchRequestSeqRef.current; try { const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); const payload = await runWithRetries(() => requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode), ); - if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + if (requestId === searchRequestSeqRef.current) { applySearchResult(payload); } } catch (requestError) { - if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + if (requestId === searchRequestSeqRef.current) { setError(requestError instanceof Error ? requestError.message : "Document search failed"); } } finally { - if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + if (requestId === searchRequestSeqRef.current) { setLoading(false); setAnswerProgress(null); }