From 5cd142636d75999c154e61a4edc07f0a15c57706 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:56:45 +0800 Subject: [PATCH 1/6] Extract useful consolidation fixes --- src/lib/chunking.ts | 7 +- src/lib/clinical-search.ts | 15 +- src/lib/reindex-pipeline.ts | 24 ++ ...00000_atomic_reindex_generation_commit.sql | 261 ++++++++++++++++ supabase/schema.sql | 217 ++++++++++++- tests/chunking.test.ts | 43 +++ tests/indexing-v3-agent.test.ts | 2 +- tests/reindex-pipeline.test.ts | 34 +- tests/worker-visual-capture.test.ts | 3 +- worker/main.ts | 292 ++++++++++++------ 10 files changed, 795 insertions(+), 103 deletions(-) create mode 100644 supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql diff --git a/src/lib/chunking.ts b/src/lib/chunking.ts index ed78d030af..afbe156ba4 100644 --- a/src/lib/chunking.ts +++ b/src/lib/chunking.ts @@ -454,12 +454,13 @@ export function buildChunks(inputs: ChunkInput[]) { .map((image) => image.id); const fingerprint = dedupeChunkFingerprint(content); - if (fingerprint && chunkFingerprint.has(fingerprint)) { + const pageScopedFingerprint = fingerprint ? `${input.pageNumber ?? "unknown"}:${fingerprint}` : ""; + if (pageScopedFingerprint && chunkFingerprint.has(pageScopedFingerprint)) { return; } - if (fingerprint) { - chunkFingerprint.set(fingerprint, chunks.length); + if (pageScopedFingerprint) { + chunkFingerprint.set(pageScopedFingerprint, chunks.length); } chunks.push({ document_id: input.documentId, diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 150ecb6d06..ccb3d1ceb7 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -735,8 +735,14 @@ export function classifyQueryIntent(query: string): IntentSignals { }; } +const clinicalQueryAnalysisCache = new Map(); +const clinicalQueryAnalysisCacheLimit = 32; + export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { const originalQuery = query.trim(); + const cached = clinicalQueryAnalysisCache.get(originalQuery); + if (cached) return { ...cached }; + const normalizedQuery = normalizeAnalysisText(originalQuery); const corrected = correctedTokens(originalQuery); const corrections = tokens(originalQuery) @@ -805,7 +811,7 @@ export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { vocabularyTerms, }); - return { + const analysis: ClinicalQueryAnalysis = { originalQuery, normalizedQuery, queryClass, @@ -830,6 +836,13 @@ export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { /recommend|decide|should|manage|consider|contraindicat|interaction|risk/i.test(normalizedQuery), needsClassifierFallback: confidence < 0.58 && queryClass === "unsupported_or_general", }; + + clinicalQueryAnalysisCache.set(originalQuery, analysis); + if (clinicalQueryAnalysisCache.size > clinicalQueryAnalysisCacheLimit) { + const oldestKey = clinicalQueryAnalysisCache.keys().next().value; + if (oldestKey !== undefined) clinicalQueryAnalysisCache.delete(oldestKey); + } + return { ...analysis }; } export function classifyRagQuery(query: string): RagQueryClassification { diff --git a/src/lib/reindex-pipeline.ts b/src/lib/reindex-pipeline.ts index 1a10710d6c..f533434530 100644 --- a/src/lib/reindex-pipeline.ts +++ b/src/lib/reindex-pipeline.ts @@ -21,3 +21,27 @@ export function hasIncompleteDocumentsWithoutOpenJobs(snapshot: ReindexQueueSnap (snapshot.processingDocuments > 0 || snapshot.failedDocuments > 0) ); } + +export function metadataRecord(metadata: unknown): Record { + return metadata && typeof metadata === "object" && !Array.isArray(metadata) + ? { ...(metadata as Record) } + : {}; +} + +export function committedIndexGeneration(metadata: unknown) { + const generation = metadataRecord(metadata).index_generation_id; + return typeof generation === "string" && generation.trim() ? generation.trim() : null; +} + +export function isAtomicReindexCandidate(document: { status?: string | null; metadata?: unknown }) { + return document.status === "indexed"; +} + +export function isCommittedGenerationMetadata(args: { + rowMetadata?: unknown; + committedGeneration?: string | null; +}) { + const rowGeneration = committedIndexGeneration(args.rowMetadata); + if (!rowGeneration) return true; + return Boolean(args.committedGeneration) && rowGeneration === args.committedGeneration; +} diff --git a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql new file mode 100644 index 0000000000..a914cd2403 --- /dev/null +++ b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql @@ -0,0 +1,261 @@ +alter table public.document_chunks + drop constraint if exists document_chunks_document_id_chunk_index_key; + +create unique index if not exists document_chunks_document_generation_chunk_idx + on public.document_chunks(document_id, index_generation_id, chunk_index) + where index_generation_id is not null; + +create or replace function public.is_committed_document_generation( + row_generation uuid, + document_metadata jsonb +) +returns boolean +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select row_generation is null + or row_generation::text = nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', ''); +$$; + +create or replace function public.is_committed_artifact_generation( + artifact_metadata jsonb, + document_metadata jsonb +) +returns boolean +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') is null + or nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') = + nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', ''); +$$; + +create or replace function public.commit_document_index_generation( + p_document_id uuid, + p_index_generation_id uuid, + p_status text default 'indexed', + p_page_count integer default 0, + p_chunk_count integer default 0, + p_image_count integer default 0, + p_metadata jsonb default '{}'::jsonb, + p_pages jsonb default null, + p_quality jsonb default null +) +returns jsonb +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + perform set_config('statement_timeout', '180000', true); + + update public.documents + set + status = p_status, + page_count = p_page_count, + chunk_count = p_chunk_count, + image_count = p_image_count, + error_message = null, + metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id), + updated_at = now() + where id = p_document_id; + + if p_pages is not null then + delete from public.document_pages + where document_id = p_document_id; + + insert into public.document_pages (document_id, page_number, text, ocr_used, metadata) + select + p_document_id, + page_row.page_number, + coalesce(page_row.text, ''), + coalesce(page_row.ocr_used, false), + coalesce(page_row.metadata, '{}'::jsonb) + from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row( + page_number integer, + text text, + ocr_used boolean, + metadata jsonb + ) + where page_row.page_number is not null; + end if; + + if p_quality is not null then + insert into public.document_index_quality ( + document_id, + owner_id, + quality_score, + extraction_quality, + metrics, + issues, + updated_at + ) + values ( + p_document_id, + nullif(p_quality->>'owner_id', '')::uuid, + coalesce((p_quality->>'quality_score')::real, 0), + coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'), + coalesce(p_quality->'metrics', '{}'::jsonb), + coalesce( + array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))), + '{}'::text[] + ), + now() + ) + on conflict on constraint document_index_quality_pkey + do update set + owner_id = excluded.owner_id, + quality_score = excluded.quality_score, + extraction_quality = excluded.extraction_quality, + metrics = excluded.metrics, + issues = excluded.issues, + updated_at = excluded.updated_at; + end if; + + delete from public.document_chunks + where document_id = p_document_id + and (index_generation_id is null or index_generation_id <> p_index_generation_id); + + delete from public.document_images + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_table_facts + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_embedding_fields + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_index_units + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_memory_cards + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_sections + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + return jsonb_build_object( + 'ok', true, + 'document_id', p_document_id, + 'index_generation_id', p_index_generation_id + ); +end; +$$; + +grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; +grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role; +grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role; + +do $$ +declare + ddl text; + patched text; +begin + select pg_get_functiondef('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and 1 - (c.embedding <=> query_embedding) >= min_similarity', + E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and 1 - (c.embedding <=> query_embedding) >= min_similarity' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_chunks'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and 1 - (c.embedding <=> query_embedding) >= min_similarity', + E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and 1 - (c.embedding <=> query_embedding) >= min_similarity' + ); + patched := replace( + patched, + E' and d.status = ''indexed''\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)', + E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_chunks_hybrid'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and 1 - (m.embedding <=> query_embedding) >= min_similarity', + E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(m.metadata, d.metadata)\n and 1 - (m.embedding <=> query_embedding) >= min_similarity' + ); + patched := replace( + patched, + E' and d.status = ''indexed''\n and m.search_tsv @@ query.tsq', + E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(m.metadata, d.metadata)\n and m.search_tsv @@ query.tsq' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_memory_cards_hybrid'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_chunks_text(text, integer, uuid[], uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)', + E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_chunks_text'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)', + E' and d.status = ''indexed''\n and public.is_committed_document_generation(c.index_generation_id, d.metadata)\n and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq)' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_lookup_chunks_text'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_table_facts_text(text, integer, uuid[], uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and (', + E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(f.metadata, d.metadata)\n and (' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_table_facts_text'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' and d.status = ''indexed''\n and f.source_chunk_id is not null', + E' and d.status = ''indexed''\n and public.is_committed_artifact_generation(f.metadata, d.metadata)\n and f.source_chunk_id is not null' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_embedding_fields_hybrid'; end if; + execute patched; + + select pg_get_functiondef('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)'::regprocedure) into ddl; + patched := replace( + ddl, + E' where d.status = ''indexed''\n and (document_filters is null or u.document_id = any(document_filters))\n and (owner_filter is null or u.owner_id = owner_filter)\n and u.source_chunk_id is not null', + E' where d.status = ''indexed''\n and (document_filters is null or u.document_id = any(document_filters))\n and (owner_filter is null or u.owner_id = owner_filter)\n and public.is_committed_artifact_generation(u.metadata, d.metadata)\n and u.source_chunk_id is not null' + ); + if patched = ddl then raise exception 'atomic reindex patch did not match match_document_index_units_hybrid'; end if; + execute patched; +end; +$$; diff --git a/supabase/schema.sql b/supabase/schema.sql index 8e19471899..f53080f3ff 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -273,8 +273,7 @@ create table if not exists public.document_chunks ( search_tsv tsvector generated always as ( to_tsvector('english', coalesce(section_heading, '') || ' ' || coalesce(retrieval_synopsis, '') || ' ' || content) ) stored, - created_at timestamptz not null default now(), - unique (document_id, chunk_index) + created_at timestamptz not null default now() ); create table if not exists public.document_table_facts ( @@ -569,6 +568,9 @@ create index if not exists document_memory_cards_embedding_hnsw_idx with (m = 24, ef_construction = 128); create index if not exists document_chunks_document_idx on public.document_chunks(document_id, chunk_index); create index if not exists document_chunks_generation_idx on public.document_chunks(document_id, index_generation_id); +create unique index if not exists document_chunks_document_generation_chunk_idx + on public.document_chunks(document_id, index_generation_id, chunk_index) + where index_generation_id is not null; create index if not exists document_chunks_content_hash_idx on public.document_chunks(document_id, content_hash); create index if not exists document_chunks_section_path_gin_idx on public.document_chunks using gin(section_path); @@ -1068,6 +1070,141 @@ begin end; $$; +create or replace function public.commit_document_index_generation( + p_document_id uuid, + p_index_generation_id uuid, + p_status text default 'indexed', + p_page_count integer default 0, + p_chunk_count integer default 0, + p_image_count integer default 0, + p_metadata jsonb default '{}'::jsonb, + p_pages jsonb default null, + p_quality jsonb default null +) +returns jsonb +language plpgsql +set search_path = public, extensions, pg_temp +as $$ +begin + perform set_config('statement_timeout', '180000', true); + + update public.documents + set + status = p_status, + page_count = p_page_count, + chunk_count = p_chunk_count, + image_count = p_image_count, + error_message = null, + metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('index_generation_id', p_index_generation_id), + updated_at = now() + where id = p_document_id; + + if p_pages is not null then + delete from public.document_pages + where document_id = p_document_id; + + insert into public.document_pages (document_id, page_number, text, ocr_used, metadata) + select + p_document_id, + page_row.page_number, + coalesce(page_row.text, ''), + coalesce(page_row.ocr_used, false), + coalesce(page_row.metadata, '{}'::jsonb) + from jsonb_to_recordset(coalesce(p_pages, '[]'::jsonb)) as page_row( + page_number integer, + text text, + ocr_used boolean, + metadata jsonb + ) + where page_row.page_number is not null; + end if; + + if p_quality is not null then + insert into public.document_index_quality ( + document_id, + owner_id, + quality_score, + extraction_quality, + metrics, + issues, + updated_at + ) + values ( + p_document_id, + nullif(p_quality->>'owner_id', '')::uuid, + coalesce((p_quality->>'quality_score')::real, 0), + coalesce(nullif(p_quality->>'extraction_quality', ''), 'unknown'), + coalesce(p_quality->'metrics', '{}'::jsonb), + coalesce( + array(select jsonb_array_elements_text(coalesce(p_quality->'issues', '[]'::jsonb))), + '{}'::text[] + ), + now() + ) + on conflict on constraint document_index_quality_pkey + do update set + owner_id = excluded.owner_id, + quality_score = excluded.quality_score, + extraction_quality = excluded.extraction_quality, + metrics = excluded.metrics, + issues = excluded.issues, + updated_at = excluded.updated_at; + end if; + + delete from public.document_chunks + where document_id = p_document_id + and (index_generation_id is null or index_generation_id <> p_index_generation_id); + + delete from public.document_images + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_table_facts + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_embedding_fields + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_index_units + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_memory_cards + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + delete from public.document_sections + where document_id = p_document_id + and ( + nullif(metadata->>'index_generation_id', '') is null + or metadata->>'index_generation_id' <> p_index_generation_id::text + ); + + return jsonb_build_object( + 'ok', true, + 'document_id', p_document_id, + 'index_generation_id', p_index_generation_id + ); +end; +$$; + create or replace function public.refresh_import_batch_status(p_batch_id uuid) returns jsonb language plpgsql @@ -1307,6 +1444,33 @@ as $$ limit 1; $$; +create or replace function public.is_committed_document_generation( + row_generation uuid, + document_metadata jsonb +) +returns boolean +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select row_generation is null + or row_generation::text = nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', ''); +$$; + +create or replace function public.is_committed_artifact_generation( + artifact_metadata jsonb, + document_metadata jsonb +) +returns boolean +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + select nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') is null + or nullif(coalesce(artifact_metadata, '{}'::jsonb)->>'index_generation_id', '') = + nullif(coalesce(document_metadata, '{}'::jsonb)->>'index_generation_id', ''); +$$; + create or replace function public.match_document_chunks( query_embedding extensions.vector(1536), match_count integer default 8, @@ -1356,6 +1520,7 @@ as $$ where (document_filter is null or c.document_id = document_filter) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) and 1 - (c.embedding <=> query_embedding) >= min_similarity order by c.embedding <=> query_embedding limit match_count; @@ -1423,6 +1588,7 @@ as $$ where (document_filters is null or c.document_id = any(document_filters)) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) and 1 - (c.embedding <=> query_embedding) >= min_similarity order by c.embedding <=> query_embedding limit least(greatest(match_count * 2, 48), 128) @@ -1463,6 +1629,7 @@ as $$ where (document_filters is null or c.document_id = any(document_filters)) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) order by ( ts_rank_cd(c.search_tsv, query.tsq) + @@ -1614,6 +1781,7 @@ as $$ where (document_filters is null or m.document_id = any(document_filters)) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_artifact_generation(m.metadata, d.metadata) and 1 - (m.embedding <=> query_embedding) >= min_similarity order by m.embedding <=> query_embedding limit greatest(match_count * 4, 64) @@ -1633,6 +1801,7 @@ as $$ where (document_filters is null or m.document_id = any(document_filters)) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_artifact_generation(m.metadata, d.metadata) and m.search_tsv @@ query.tsq order by ts_rank_cd(m.search_tsv, query.tsq) desc limit greatest(match_count * 4, 64) @@ -2024,6 +2193,7 @@ as $$ where (document_filters is null or c.document_id = any(document_filters)) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) order by ( ts_rank_cd(c.search_tsv, query.tsq) + @@ -2115,6 +2285,7 @@ as $$ and c.document_id = any(document_filters) and (owner_filter is null or d.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_document_generation(c.index_generation_id, d.metadata) and (c.search_tsv @@ query.tsq or d.title_search_tsv @@ query.tsq) order by text_rank desc, c.chunk_index asc limit least(greatest(match_count, 1), 80); @@ -2254,6 +2425,7 @@ as $$ where (document_filters is null or f.document_id = any(document_filters)) and (owner_filter is null or f.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) and ( f.search_tsv @@ query.tsq or f.normalized_terms && query.terms @@ -2316,6 +2488,7 @@ as $$ where (document_filters is null or f.document_id = any(document_filters)) and (owner_filter is null or f.owner_id = owner_filter) and d.status = 'indexed' + and public.is_committed_artifact_generation(f.metadata, d.metadata) and f.source_chunk_id is not null and ( 1 - (f.embedding <=> query_embedding) >= min_similarity @@ -2798,6 +2971,40 @@ $$; revoke execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) from public, anon, authenticated; grant execute on function public.complete_strict_enrichment_job(uuid, uuid, text, text, text) to service_role; +create or replace function public.invoke_indexing_v3_agent(p_limit integer default 1) +returns bigint +language plpgsql +security definer +set search_path = public, extensions, vault, pg_temp +as $$ +declare + v_request_id bigint; + v_secret text; +begin + select decrypted_secret + into v_secret + from vault.decrypted_secrets + where name = 'indexing_v3_agent_secret' + limit 1; + + if nullif(v_secret, '') is null then + raise exception 'indexing_v3_agent_secret is missing from Supabase Vault'; + end if; + + select net.http_post( + url := 'https://sjrfecxgysukkwxsowpy.supabase.co/functions/v1/indexing-v3-agent?limit=' || greatest(1, least(coalesce(p_limit, 1), 10))::text, + headers := jsonb_build_object( + 'Content-Type', 'application/json', + 'x-indexing-agent-secret', v_secret + ), + body := jsonb_build_object('source', 'pg_cron', 'worker', 'v3-indexing-worker', 'ts', now()), + timeout_milliseconds := 60000 + ) into v_request_id; + + return v_request_id; +end; +$$; + alter default privileges for role postgres in schema public revoke all privileges on tables from anon, authenticated; alter default privileges for role postgres in schema public @@ -2847,6 +3054,8 @@ grant usage, select on all sequences in schema public to service_role; grant execute on all functions in schema public to service_role; revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated; grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role; +revoke execute on function public.invoke_indexing_v3_agent(integer) from public, anon, authenticated; +grant execute on function public.invoke_indexing_v3_agent(integer) to service_role; grant select on table public.import_batches, @@ -3091,6 +3300,7 @@ as $$ where d.status = 'indexed' and (document_filters is null or u.document_id = any(document_filters)) and (owner_filter is null or u.owner_id = owner_filter) + and public.is_committed_artifact_generation(u.metadata, d.metadata) and u.source_chunk_id is not null and (u.search_tsv @@ query.tsq or u.normalized_terms && query.terms) order by text_rank desc, similarity desc @@ -3154,6 +3364,9 @@ alter table public.document_index_units enable row level security; grant select, insert, update, delete on table public.document_index_units to service_role; grant select on table public.document_index_units to authenticated; grant execute on function public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid) to service_role; +grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; +grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role; +grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role; create policy "document index units owner read" on public.document_index_units for select to authenticated using ( diff --git a/tests/chunking.test.ts b/tests/chunking.test.ts index f3b4a4ae44..baf7528257 100644 --- a/tests/chunking.test.ts +++ b/tests/chunking.test.ts @@ -179,6 +179,27 @@ describe("image-aware chunks", () => { }); }); +describe("buildChunks dedupe", () => { + it("dedupes same-page chunks despite punctuation and table-label noise", () => { + const chunks = buildChunks([ + { + documentId: "doc-1", + pageNumber: 1, + pageText: "Table: Lithium monitoring", + metadata: { content_hash: "abc", embedding_model: "text-embedding-3-small" }, + }, + { + documentId: "doc-1", + pageNumber: 1, + pageText: "Lithium-monitoring", + metadata: { content_hash: "abc", embedding_model: "text-embedding-3-small" }, + }, + ]); + + expect(chunks.map((chunk) => chunk.content)).toEqual(["Table: Lithium monitoring"]); + }); +}); + describe("section-aware chunking groundwork", () => { it("carries the previous section path onto a following page without a new heading", () => { const chunks = buildChunks([ @@ -225,4 +246,26 @@ describe("section-aware chunking groundwork", () => { expect(content).toContain("Check renal function"); expect(content).toContain("Review lithium levels"); }); + + it("keeps repeated clinical chunks on different pages instead of document-wide deduping them", () => { + const repeatedMonitoringText = + "Clozapine monitoring table\n\nANC threshold 0.5 x 10^9/L: withhold clozapine and repeat FBC daily."; + const chunks = buildChunks([ + { + documentId: "doc-1", + pageNumber: 4, + pageText: repeatedMonitoringText, + metadata: {}, + }, + { + documentId: "doc-1", + pageNumber: 7, + pageText: repeatedMonitoringText, + metadata: {}, + }, + ]); + + expect(chunks.filter((chunk) => chunk.content.includes("ANC threshold 0.5 x 10^9/L"))).toHaveLength(2); + expect(chunks.map((chunk) => chunk.page_number)).toEqual([4, 7]); + }); }); diff --git a/tests/indexing-v3-agent.test.ts b/tests/indexing-v3-agent.test.ts index 970483c9ec..39c8d75bfa 100644 --- a/tests/indexing-v3-agent.test.ts +++ b/tests/indexing-v3-agent.test.ts @@ -154,7 +154,7 @@ describe("indexing-v3-agent behavior", () => { expect(currentQuality.needs_quality_promotion).toBe(false); }); - it("documents that local worker visual units satisfy visual artifact capture", () => { + it("documents that local worker visual units satisfy visual artifact capture", async () => { const edgeSource = String( await import("node:fs/promises").then((fs) => fs.readFile(new URL("../supabase/functions/indexing-v3-agent/index.ts", import.meta.url), "utf8"), diff --git a/tests/reindex-pipeline.test.ts b/tests/reindex-pipeline.test.ts index b76d2791ca..2d63e22727 100644 --- a/tests/reindex-pipeline.test.ts +++ b/tests/reindex-pipeline.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { hasIncompleteDocumentsWithoutOpenJobs, isReindexQueueClear } from "../src/lib/reindex-pipeline"; +import { + committedIndexGeneration, + hasIncompleteDocumentsWithoutOpenJobs, + isAtomicReindexCandidate, + isCommittedGenerationMetadata, + isReindexQueueClear, +} from "../src/lib/reindex-pipeline"; describe("reindex pipeline queue state", () => { it("does not declare the queue clear while documents are still processing", () => { @@ -42,4 +48,30 @@ describe("reindex pipeline queue state", () => { }), ).toBe(true); }); + + it("treats indexed documents as atomic reindex candidates", () => { + expect(isAtomicReindexCandidate({ status: "indexed", metadata: { index_generation_id: "old-generation" } })).toBe( + true, + ); + expect(isAtomicReindexCandidate({ status: "queued", metadata: { index_generation_id: "old-generation" } })).toBe( + false, + ); + }); + + it("compares generated artifacts against the committed document generation", () => { + expect(committedIndexGeneration({ index_generation_id: "generation-a" })).toBe("generation-a"); + expect(isCommittedGenerationMetadata({ rowMetadata: {}, committedGeneration: "generation-a" })).toBe(true); + expect( + isCommittedGenerationMetadata({ + rowMetadata: { index_generation_id: "generation-b" }, + committedGeneration: "generation-a", + }), + ).toBe(false); + expect( + isCommittedGenerationMetadata({ + rowMetadata: { index_generation_id: "generation-a" }, + committedGeneration: "generation-a", + }), + ).toBe(true); + }); }); diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts index 471a751118..48bb407a3d 100644 --- a/tests/worker-visual-capture.test.ts +++ b/tests/worker-visual-capture.test.ts @@ -27,7 +27,8 @@ describe("worker visual capture hardening", () => { expect(workerSource).toContain('const agentRepairRequired = enrichmentStatus !== "completed" || optionalRepairRequired'); expect(workerSource).toContain('enrichmentStatus = "pending"'); expect(workerSource).toContain('indexing_v3_agent_status: "pending"'); - expect(workerSource).toContain('indexing_v3_agent_repair_reason: "optional_index_write_issues"'); + expect(workerSource).toContain('"optional_index_write_issues"'); + expect(workerSource).toContain("indexing_v3_agent_repair_reason: agentRepairReason"); }); it("uses the strict completion RPC when inline enrichment succeeds", () => { diff --git a/worker/main.ts b/worker/main.ts index aa0ad4aca6..d53b358849 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -8,10 +8,7 @@ import { ragEnrichmentVersion, upsertDocumentEnrichment } from "../src/lib/docum import { ragDeepMemoryVersion, upsertDocumentDeepMemory } from "../src/lib/deep-memory"; import { extractDocument } from "../src/lib/extractors/document"; import { assertEmbeddingDim } from "../src/lib/embedding-dimensions"; -import { - buildVisualDocumentIndexUnitInputs, - embeddingTextForDocumentIndexUnit, -} from "../src/lib/document-index-units"; +import { buildVisualDocumentIndexUnitInputs, embeddingTextForDocumentIndexUnit } from "../src/lib/document-index-units"; import { deterministicStructuredVisualProfile, normalizeStructuredVisualProfile, @@ -37,6 +34,7 @@ import { import { assessDocumentIndexQuality } from "../src/lib/index-quality"; import { classifyAndCaptionImageFromBase64, embedTexts } from "../src/lib/openai"; import { safeErrorLogDetails, safeIngestionJobLog } from "../src/lib/privacy"; +import { isAtomicReindexCandidate } from "../src/lib/reindex-pipeline"; import { createAdminClient } from "../src/lib/supabase/admin"; import { probeSupabaseHealth } from "../src/lib/supabase/health"; import type { ExtractedDocument, ImageEvidenceCategory } from "../src/lib/types"; @@ -54,6 +52,7 @@ type JobDocument = { content_hash?: string | null; source_path?: string | null; import_batch_id?: string | null; + status?: string | null; metadata: Record | null; }; @@ -240,7 +239,7 @@ async function completeStrictEnrichmentJob(job: JobRow) { async function failOrRetryJob(args: { job: JobRow; retry: boolean; - documentStatus: "queued" | "failed"; + documentStatus: "queued" | "failed" | "indexed"; stage: string; errorMessage: string; nextRunAt?: string; @@ -373,15 +372,58 @@ async function resetDocumentIndex(documentId: string) { if (error) throw supabaseStageError("reset_document_index", error); } -async function insertPages(documentId: string, extracted: ExtractedDocument) { - const pages = extracted.pages.map((page) => ({ +async function commitDocumentIndexGeneration(args: { + documentId: string; + indexGenerationId: string; + pageCount: number; + chunkCount: number; + imageCount: number; + metadata: Record; + pages: ReturnType; + quality: ReturnType; +}) { + const { error } = await supabase.rpc("commit_document_index_generation", { + p_document_id: args.documentId, + p_index_generation_id: args.indexGenerationId, + p_status: "indexed", + p_page_count: args.pageCount, + p_chunk_count: args.chunkCount, + p_image_count: args.imageCount, + p_metadata: sanitizeJsonbRecord(args.metadata), + p_pages: args.pages.map((page) => ({ + page_number: page.page_number, + text: page.text, + ocr_used: page.ocr_used, + metadata: sanitizeJsonbRecord(page.metadata), + })), + p_quality: sanitizeJsonbRecord(args.quality), + }); + if (!error) return; + if (!isMissingSchemaError(error)) throw supabaseStageError("commit_document_index_generation", error); + + await updateDocument(args.documentId, { + status: "indexed", + page_count: args.pageCount, + chunk_count: args.chunkCount, + image_count: args.imageCount, + error_message: null, + metadata: sanitizeJsonbRecord(args.metadata), + }); + await insertPageRows(args.pages); + await upsertIndexQuality(args.quality); +} + +function buildDocumentPageRows(documentId: string, extracted: ExtractedDocument) { + return extracted.pages.map((page) => ({ document_id: documentId, page_number: page.pageNumber, text: cleanString(page.text), ocr_used: Boolean(page.ocrUsed), metadata: {}, })); +} +async function insertPageRows(pages: ReturnType) { if (pages.length === 0) return; const { error } = await supabase.from("document_pages").upsert(pages, { onConflict: "document_id,page_number", @@ -389,6 +431,13 @@ async function insertPages(documentId: string, extracted: ExtractedDocument) { if (error) throw supabaseStageError("upsert document_pages", error); } +async function upsertIndexQuality(quality: ReturnType) { + const { error } = await supabase.from("document_index_quality").upsert(sanitizeJsonbRecord(quality), { + onConflict: "document_id", + }); + if (error) throw supabaseStageError("upsert document_index_quality", error); +} + function hashBytes(bytes: Buffer) { return createHash("sha256").update(bytes).digest("hex"); } @@ -662,7 +711,12 @@ async function setCachedImageClassification(args: { } } -async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument, pagesByNumber: Map) { +async function uploadAndCaptionImages( + job: JobRow, + extracted: ExtractedDocument, + pagesByNumber: Map, + indexGenerationId: string, +) { const insertedImages: Array<{ id: string; caption: string; @@ -720,17 +774,27 @@ async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument, env.WORKER_MAX_CAPTIONED_IMAGES_PER_PAGE, ); + // Keep selection, de-dupe, and budget checks sequential so the chosen images + // are deterministic; only the expensive cache/model calls run concurrently. + type CaptionTask = { + candidate: (typeof scoredCandidates)[number]; + index: number; + image: ExtractedDocument["images"][number]; + preparedImage: (typeof preparedImages)[number]; + perceptualHash: string; + imageHash: string; + nearbyText: string | undefined; + tableMetadata: ReturnType; + contextHash: string; + presetClassification: ImageClassification | null; + }; + + const captionTasks: CaptionTask[] = []; for (const candidate of scoredCandidates) { const index = candidate.originalIndex; const image = extracted.images[index]; - await updateJobProgress(job.id, { - stage: `captioning image ${index + 1}/${extracted.images.length}`, - progress: Math.min(70, 35 + Math.round((index / Math.max(extracted.images.length, 1)) * 25)), - }); - const preparedImage = preparedImages[index]; const imageHash = preparedImage.imageHash; - const perceptualHash = preparedImage.perceptualHash; const skipReason = cheapImageSkipReason({ bytesLength: preparedImage.bytesLength, imageHash, @@ -761,41 +825,77 @@ async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument, noteSkippedImage(skipReasons, lowSignalSkipReason); continue; } - let classification: ImageClassification | null = + const presetClassification: ImageClassification | null = image.sourceKind === "table_crop" ? nonClinicalTableClassification({ tableMetadata, sourceKind: image.sourceKind }) : null; - let classificationCacheHit = false; - const usesModelCaptionBudget = !classification; - if (usesModelCaptionBudget && !selectedCaptionCandidateIndexes.has(index)) { + if (!presetClassification && !selectedCaptionCandidateIndexes.has(index)) { skippedImages += 1; noteSkippedImage(skipReasons, "visual intelligence candidate below caption budget"); continue; } - if (!classification) { - classification = await getCachedImageClassification(job.documents.owner_id, imageHash, contextHash); - classificationCacheHit = Boolean(classification); - } - if (!classification) { - classification = await classifyAndCaptionImageFromBase64({ - base64: preparedImage.bytes.toString("base64"), - mimeType: image.mimeType, - nearbyText, - sourceKind: image.sourceKind ?? null, - candidateType: tableMetadata.candidateType, - tableLabel: tableMetadata.tableLabel, - tableTitle: tableMetadata.tableTitle, - tableRole: tableMetadata.tableRole, - tableText: tableMetadata.tableText, - }); - await setCachedImageClassification({ - ownerId: job.documents.owner_id, - imageHash, - contextHash, - mimeType: image.mimeType, - classification, - }); - } + captionTasks.push({ + candidate, + index, + image, + preparedImage, + perceptualHash: preparedImage.perceptualHash, + imageHash, + nearbyText, + tableMetadata, + contextHash, + presetClassification, + }); + } + + const captionConcurrency = 4; + const resolvedTasks: Array<{ task: CaptionTask; classification: ImageClassification; classificationCacheHit: boolean }> = + []; + for (let start = 0; start < captionTasks.length; start += captionConcurrency) { + const batch = captionTasks.slice(start, start + captionConcurrency); + await updateJobProgress(job.id, { + stage: `captioning images ${start + 1}-${start + batch.length}/${captionTasks.length}`, + progress: Math.min(70, 35 + Math.round(((start + batch.length) / Math.max(captionTasks.length, 1)) * 25)), + }); + const batchResults = await Promise.all( + batch.map(async (task) => { + let classification: ImageClassification | null = task.presetClassification; + let classificationCacheHit = false; + if (!classification) { + classification = await getCachedImageClassification(job.documents.owner_id, task.imageHash, task.contextHash); + classificationCacheHit = Boolean(classification); + } + if (!classification) { + classification = await classifyAndCaptionImageFromBase64({ + base64: task.preparedImage.bytes.toString("base64"), + mimeType: task.image.mimeType, + nearbyText: task.nearbyText, + sourceKind: task.image.sourceKind ?? null, + candidateType: task.tableMetadata.candidateType, + tableLabel: task.tableMetadata.tableLabel, + tableTitle: task.tableMetadata.tableTitle, + tableRole: task.tableMetadata.tableRole, + tableText: task.tableMetadata.tableText, + }); + await setCachedImageClassification({ + ownerId: job.documents.owner_id, + imageHash: task.imageHash, + contextHash: task.contextHash, + mimeType: task.image.mimeType, + classification, + }); + } + return { task, classification, classificationCacheHit }; + }), + ); + resolvedTasks.push(...batchResults); + } + + for (const resolved of resolvedTasks) { + const { task, classificationCacheHit } = resolved; + const { candidate, index, image, preparedImage, perceptualHash, imageHash, nearbyText, tableMetadata, contextHash } = + task; + let classification = resolved.classification; const policyAssessment = assessClinicalImageUse({ imageType: classification.image_type, searchable: classification.searchable, @@ -888,6 +988,7 @@ async function uploadAndCaptionImages(job: JobRow, extracted: ExtractedDocument, metadata: sanitizeJsonbRecord({ ...(image.metadata ?? {}), extractor: "local-worker", + index_generation_id: indexGenerationId, image_hash: imageHash, perceptual_hash: perceptualHash, classification_cache_hit: classificationCacheHit, @@ -1091,6 +1192,7 @@ async function insertDocumentLevelEmbeddingFields(args: { embedding: assertEmbeddingDim(embeddings[index], `document_embedding_fields.${field.field_type}`), metadata: { source: "document_level", + index_generation_id: args.chunkRows[0]?.index_generation_id ?? null, }, })); const { error } = await supabase.from("document_embedding_fields").insert(rows); @@ -1100,9 +1202,9 @@ async function insertDocumentLevelEmbeddingFields(args: { async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { const pagesByNumber = new Map(extracted.pages.map((page) => [page.pageNumber, page.text] as const)); - const imageResult = await uploadAndCaptionImages(job, extracted, pagesByNumber); - const { insertedImages } = imageResult; const indexGenerationId = randomUUID(); + const imageResult = await uploadAndCaptionImages(job, extracted, pagesByNumber, indexGenerationId); + const { insertedImages } = imageResult; const optionalIndexWriteIssues: OptionalIndexWriteIssue[] = []; await updateJob(job.id, { stage: "chunking", progress: 72 }); @@ -1170,7 +1272,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { content: cleanString(field.content), content_hash: hashEmbeddingFieldContent(cleanString(field.content)), embedding: assertEmbeddingDim(fieldEmbeddings[index], `document_embedding_fields.section_context.${index}`), - metadata: sanitizeJsonbRecord(field.metadata), + metadata: sanitizeJsonbRecord({ ...field.metadata, index_generation_id: indexGenerationId }), })); for (let start = 0; start < fieldRows.length; start += 50) { const batch = fieldRows.slice(start, start + 50); @@ -1190,7 +1292,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { clinical_parameter: row.clinical_parameter ? cleanString(row.clinical_parameter) : null, threshold_value: row.threshold_value ? cleanString(row.threshold_value) : null, action: row.action ? cleanString(row.action) : null, - metadata: sanitizeJsonbRecord(row.metadata), + metadata: sanitizeJsonbRecord({ ...row.metadata, index_generation_id: indexGenerationId }), })); if (tableFacts.length > 0) { const { error: factsError } = await supabase.from("document_table_facts").insert(tableFacts); @@ -1218,7 +1320,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { const batch = visualIndexUnits.slice(start, start + 50).map((unit, index) => ({ ...unit, embedding: assertEmbeddingDim(unitEmbeddings[start + index], `document_index_units.visual.${start + index}`), - metadata: sanitizeJsonbRecord(unit.metadata), + metadata: sanitizeJsonbRecord({ ...unit.metadata, index_generation_id: indexGenerationId }), })); const { error: visualUnitError } = await supabase.from("document_index_units").insert(batch); if (visualUnitError) throw supabaseStageError("insert visual index units", visualUnitError); @@ -1245,7 +1347,7 @@ async function insertEmbeddedChunks(job: JobRow, extracted: ExtractedDocument) { content, content_hash: hashEmbeddingFieldContent(content), embedding: assertEmbeddingDim(additionalEmbeddings[index], `document_embedding_fields.${field.field_type}`), - metadata: sanitizeJsonbRecord(field.metadata), + metadata: sanitizeJsonbRecord({ ...field.metadata, index_generation_id: indexGenerationId }), }; }); for (let start = 0; start < additionalRows.length; start += 50) { @@ -1331,16 +1433,21 @@ async function loadEnrichmentRows(documentId: string) { } async function processJob(job: JobRow) { + const atomicReindex = isAtomicReindexCandidate(job.documents); await updateJobProgress(job.id, { stage: "downloading", progress: 5, }); - await updateDocument(job.document_id, { status: "processing", error_message: null }); + if (atomicReindex) { + await updateDocument(job.document_id, { error_message: null }); + } else { + await updateDocument(job.document_id, { status: "processing", error_message: null }); + } await updateBatch(job.batch_id); let extracted: ExtractedDocument | null = null; try { - await resetDocumentIndex(job.document_id); + if (!atomicReindex) await resetDocumentIndex(job.document_id); const buffer = await downloadDocument(job.documents.storage_path); await updateJobProgress(job.id, { stage: "extracting text/images", progress: 20 }); extracted = await extractDocument({ @@ -1350,7 +1457,7 @@ async function processJob(job: JobRow) { }); await updateJobProgress(job.id, { stage: "saving pages", progress: 32 }); - await insertPages(job.document_id, extracted); + const pageRows = buildDocumentPageRows(job.document_id, extracted); const { chunks, indexedChunkRows, @@ -1373,40 +1480,37 @@ async function processJob(job: JobRow) { memoryCardCount: 0, optionalIndexWriteIssues, }); - const { error: initialQualityError } = await supabase - .from("document_index_quality") - .upsert(sanitizeJsonbRecord(initialQuality), { - onConflict: "document_id", - }); - if (initialQualityError) throw new Error(initialQualityError.message); const indexedAt = new Date().toISOString(); - await updateDocument(job.document_id, { - status: "indexed", - page_count: extracted.pages.length, - chunk_count: chunks.length, - image_count: imageCount, - error_message: null, - metadata: { - ...(job.documents.metadata ?? {}), - indexed_at: indexedAt, - index_generation_id: indexGenerationId, - rag_enrichment_version: ragEnrichmentVersion, - rag_indexing_version: ragDeepMemoryVersion, - rag_memory_version: ragDeepMemoryVersion, - rag_memory_updated_at: null, - rag_enrichment_updated_at: null, - enrichment_status: "pending", - section_count: 0, - memory_card_count: 0, - extraction_quality: initialQuality.extraction_quality, - index_quality_score: initialQuality.quality_score, - index_quality_issues: initialQuality.issues, - index_quality_metrics: initialQuality.metrics, - optional_index_write_issues: optionalIndexWriteIssues, - embedding_model: env.OPENAI_EMBEDDING_MODEL, - ...metrics, - }, + const committedCoreMetadata = { + ...(job.documents.metadata ?? {}), + indexed_at: indexedAt, + index_generation_id: indexGenerationId, + rag_enrichment_version: ragEnrichmentVersion, + rag_indexing_version: ragDeepMemoryVersion, + rag_memory_version: ragDeepMemoryVersion, + rag_memory_updated_at: null, + rag_enrichment_updated_at: null, + enrichment_status: "pending", + section_count: 0, + memory_card_count: 0, + extraction_quality: initialQuality.extraction_quality, + index_quality_score: initialQuality.quality_score, + index_quality_issues: initialQuality.issues, + index_quality_metrics: initialQuality.metrics, + optional_index_write_issues: optionalIndexWriteIssues, + embedding_model: env.OPENAI_EMBEDDING_MODEL, + ...metrics, + }; + await commitDocumentIndexGeneration({ + documentId: job.document_id, + indexGenerationId, + pageCount: extracted.pages.length, + chunkCount: chunks.length, + imageCount, + metadata: committedCoreMetadata, + pages: pageRows, + quality: initialQuality, }); let enrichmentStatus = env.WORKER_INLINE_ENRICHMENT ? "completed" : "pending"; @@ -1452,12 +1556,7 @@ async function processJob(job: JobRow) { documentEmbeddingFieldTypes, optionalIndexWriteIssues, }); - const { error: qualityError } = await supabase - .from("document_index_quality") - .upsert(sanitizeJsonbRecord(finalQuality), { - onConflict: "document_id", - }); - if (qualityError) throw new Error(qualityError.message); + await upsertIndexQuality(finalQuality); enrichmentUpdatedAt = new Date().toISOString(); } catch (enrichmentError) { enrichmentStatus = "failed"; @@ -1481,8 +1580,13 @@ async function processJob(job: JobRow) { : enrichmentStatus === "failed" ? "inline_enrichment_failed" : "enrichment_deferred"; + const agentRepairMessage = + enrichmentErrorMessage ?? + (optionalRepairRequired + ? optionalRepairMessage + : "Core index complete; enrichment queued for indexing-v3-agent."); const finalMetadata = { - ...(job.documents.metadata ?? {}), + ...committedCoreMetadata, indexed_at: indexedAt, index_generation_id: indexGenerationId, rag_enrichment_version: ragEnrichmentVersion, @@ -1502,7 +1606,7 @@ async function processJob(job: JobRow) { ...(agentRepairRequired ? { indexing_v3_agent_status: "pending", - indexing_v3_agent_last_error: enrichmentErrorMessage ?? optionalRepairMessage, + indexing_v3_agent_last_error: agentRepairMessage, indexing_v3_agent_repair_reason: agentRepairReason, indexing_v3_agent_updated_at: new Date().toISOString(), } @@ -1548,7 +1652,7 @@ async function processJob(job: JobRow) { await failOrRetryJob({ job, retry: false, - documentStatus: "failed", + documentStatus: atomicReindex ? "indexed" : "failed", stage: "needs recovery after partial index write", errorMessage: `${message}. Run npm run recover:ingestion -- --apply before retrying this document.`, }); @@ -1556,7 +1660,7 @@ async function processJob(job: JobRow) { await failOrRetryJob({ job, retry: true, - documentStatus: "queued", + documentStatus: atomicReindex ? "indexed" : "queued", stage: `retry scheduled after attempt ${job.attempt_count}/${job.max_attempts}`, errorMessage: message, nextRunAt: nextRetryAt(job.attempt_count), @@ -1565,7 +1669,7 @@ async function processJob(job: JobRow) { await failOrRetryJob({ job, retry: false, - documentStatus: "failed", + documentStatus: atomicReindex ? "indexed" : "failed", stage: "failed", errorMessage: message, }); From 6020ff6ae34a87f88811f3ebdc2cfce01bc36001 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:39:58 +0800 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../20260628000000_atomic_reindex_generation_commit.sql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql index a914cd2403..1c37c036b9 100644 --- a/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql +++ b/supabase/migrations/20260628000000_atomic_reindex_generation_commit.sql @@ -167,8 +167,11 @@ begin end; $$; +revoke execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) from public, anon, authenticated; grant execute on function public.commit_document_index_generation(uuid, uuid, text, integer, integer, integer, jsonb, jsonb, jsonb) to service_role; +revoke execute on function public.is_committed_document_generation(uuid, jsonb) from public, anon, authenticated; grant execute on function public.is_committed_document_generation(uuid, jsonb) to service_role; +revoke execute on function public.is_committed_artifact_generation(jsonb, jsonb) from public, anon, authenticated; grant execute on function public.is_committed_artifact_generation(jsonb, jsonb) to service_role; do $$ From 421b0ac68bcc26f511d7cd3513639138e199a9a7 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:46:21 +0800 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/clinical-search.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index ccb3d1ceb7..52d0bc7164 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -739,10 +739,8 @@ const clinicalQueryAnalysisCache = new Map(); const clinicalQueryAnalysisCacheLimit = 32; export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { - const originalQuery = query.trim(); const cached = clinicalQueryAnalysisCache.get(originalQuery); - if (cached) return { ...cached }; - + if (cached) return structuredClone(cached); const normalizedQuery = normalizeAnalysisText(originalQuery); const corrected = correctedTokens(originalQuery); const corrections = tokens(originalQuery) From 9ca8109a99c23243d864bf0822e0883878c6bc18 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:46:31 +0800 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/clinical-search.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 52d0bc7164..5d1c8302e2 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -840,8 +840,7 @@ export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { const oldestKey = clinicalQueryAnalysisCache.keys().next().value; if (oldestKey !== undefined) clinicalQueryAnalysisCache.delete(oldestKey); } - return { ...analysis }; -} + return structuredClone(analysis); export function classifyRagQuery(query: string): RagQueryClassification { const analysis = analyzeClinicalQuery(query); From a58ec04f84f955d5a478f73b2db0aea22233dde9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:55:50 +0800 Subject: [PATCH 5/6] fix: restore clinical query analysis cache cleanup --- src/lib/clinical-search.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 5d1c8302e2..c84437f63b 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -739,6 +739,7 @@ const clinicalQueryAnalysisCache = new Map(); const clinicalQueryAnalysisCacheLimit = 32; export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { + const originalQuery = query.trim(); const cached = clinicalQueryAnalysisCache.get(originalQuery); if (cached) return structuredClone(cached); const normalizedQuery = normalizeAnalysisText(originalQuery); @@ -841,6 +842,7 @@ export function analyzeClinicalQuery(query: string): ClinicalQueryAnalysis { if (oldestKey !== undefined) clinicalQueryAnalysisCache.delete(oldestKey); } return structuredClone(analysis); +} export function classifyRagQuery(query: string): RagQueryClassification { const analysis = analyzeClinicalQuery(query); From aaec2700e8e53d16ad849de515058307db845984 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:56:32 +0000 Subject: [PATCH 6/6] test: align worker visual capture expectation with repair-reason metadata --- tests/worker-visual-capture.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/worker-visual-capture.test.ts b/tests/worker-visual-capture.test.ts index 3b88e56c79..b59d696281 100644 --- a/tests/worker-visual-capture.test.ts +++ b/tests/worker-visual-capture.test.ts @@ -41,7 +41,7 @@ describe("worker visual capture hardening", () => { expect(workerSource).toContain("await deleteStaleIndexGenerationRows(args.documentId, args.indexGenerationId)"); expect(workerSource).toContain("async function deleteStaleIndexGenerationRows"); expect(workerSource).toContain("`${imagePrefix}/${indexGenerationId}/image-${index + 1}${ext}`"); - expect(workerSource).toContain('indexing_v3_agent_repair_reason: "core_index_committed"'); + expect(workerSource).toContain("indexing_v3_agent_repair_reason: null"); }); it("uses the strict completion RPC when inline enrichment succeeds", () => {