diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..647d6e1a8 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,37 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; import { isLocalNoAuthMode } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -129,42 +95,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +144,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +182,15 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +204,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +425,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

: null} - {!hasStructuredTable && sourceHeader.caption ?

{sourceHeader.caption}

: null} - - {!hasStructuredTable && item.tableTextSnippet ? ( -

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

- ) : null} - {displayLabels.length ? ( -
- {displayLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
- - {formatCompactCitationLabel(item)} - - - {cleanDisplayTitle(item.title)}, page {item.page_number ?? "n/a"} - - {item.image_type && ( - - {item.image_type.replaceAll("_", " ")} - - )} - {!hasStructuredTable ? : null} - - - Open source - -
-
- ); - })} -
- )} - - ); - - if (embedded) return
{content}
; - - return ( -
- {content} -
- ); -} - -const evidenceTabIconMap: Record = { - Claims: CheckCircle2, - Quotes: Quote, - Tables: ListChecks, - Images: FileImage, - Gaps: AlertCircle, -}; - -function supportDotClass(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "bg-[color:var(--danger)]"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) { - return "bg-[color:var(--warning)]"; - } - return "bg-[color:var(--clinical-accent)]"; -} - -function supportLabel(supportLevel: string) { - const normalized = supportLevel.toLowerCase(); - if (normalized.includes("unsupported") || normalized.includes("none")) return "Unsupported"; - if (normalized.includes("partial") || normalized.includes("limited") || normalized.includes("nearby")) - return "Partial"; - return "Direct"; -} - -function claimRowsForEvidencePanel(rows: AnswerEvidenceMapRow[], renderModel: AnswerRenderModel) { - if (rows.length) return rows.slice(0, 6); - return renderModel.primarySources.slice(0, 6).map((source, index) => ({ - id: source.id, - section: source.label || cleanDisplayTitle(source.title || source.file_name) || `Source ${index + 1}`, - detail: source.snippet || source.reason || "Open source passage to review the cited evidence.", - supportLevel: source.sourceStrength === "none" ? "partial" : source.sourceStrength, - citationCount: 1, - sourceStatus: - source.sourceStrength === "none" ? "Source requires review" : `${source.sourceStrength} source support`, - bestSourceLabel: source.label, - bestLinkedPassage: source.snippet || source.reason, - href: source.href, - })); -} - -function EvidenceClaimsList({ rows, renderModel }: { rows: AnswerEvidenceMapRow[]; renderModel: AnswerRenderModel }) { - const claimRows = claimRowsForEvidencePanel(rows, renderModel); - const directCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Direct").length; - const partialCount = claimRows.filter((row) => supportLabel(row.supportLevel) === "Partial").length; - - if (!claimRows.length) { - return ; - } - - return ( -
-
-
-

Claims checked

-
- - - Direct - - - - Partial - - - - Unsupported - -
-
-

- {directCount} direct · {partialCount} partial -

-
- -
- {claimRows.map((row, index) => ( - - - - - {row.section} - - {row.detail || row.bestLinkedPassage || row.bestSourceLabel} - - - - - ))} -
-
- ); -} - -function EvidenceGapsPanel({ warnings }: { warnings: string[] }) { - if (!warnings.length) { - return ( - - ); - } - - return ( -
- {warnings.map((warning, index) => ( -
- -
-

Gap {index + 1}

-

{warning}

-
-
- ))} -
- ); -} - -function MobileEvidenceTabPanel({ - tab, - renderModel, - visualEvidence, - answerEvidenceMapRows, - copiedQuotes, - onCopyQuotes, - onFollowUpQuote, - onScopeDocument, -}: { - tab: EvidenceTabName; - renderModel: AnswerRenderModel; - visualEvidence: VisualEvidenceCard[]; - answerEvidenceMapRows: AnswerEvidenceMapRow[]; - copiedQuotes: boolean; - onCopyQuotes: () => void; - onFollowUpQuote?: (quote: QuoteCard) => void; - onScopeDocument: (documentId: string) => void; -}) { - if (tab === "Claims") { - return ; - } - - if (tab === "Tables") { - const tableEvidence = visualEvidence.filter((item) => item.accessibleTableMarkdown || item.tableRows?.length); - return tableEvidence.length ? ( -
- {tableEvidence.slice(0, 4).map((item, index) => ( -
- - - -
-

- {compactClinicalTableCaption(item)} -

-

- Table {index + 1} · p.{item.page_number ?? "n/a"} -

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3..68072f029 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 2f84e2134..5dcca9e54 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -29,7 +29,6 @@ import { evidenceTabOrder, QuoteCards, simpleClinicalTableProps, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index ec53f934c..d07f9b17f 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -435,8 +435,10 @@ export function ServicesNavigatorPage() { const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); - const searchableRecords = - registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords; + const searchableRecords = useMemo( + () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + [registry.records, registry.status], + ); const matches = useMemo(() => { const ranked = rankServiceRecords(searchableRecords, query); return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; diff --git a/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql new file mode 100644 index 000000000..21e0c281d --- /dev/null +++ b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql @@ -0,0 +1,202 @@ +-- Reconcile live index drift reported by search_schema_health() on 2026-07-05. +-- Creates canonical retrieval-support indexes that are missing on live (or only +-- present under legacy names) and teaches search_schema_health() to accept +-- verified functional equivalents so replays do not false-fail mid-rollout. + +set search_path = public, extensions, pg_catalog; + +create index if not exists documents_title_trgm_idx + on public.documents using gin (lower(coalesce(title, '') || ' ' || coalesce(file_name, '')) gin_trgm_ops); + +create index if not exists document_chunks_content_trgm_idx + on public.document_chunks using gin (lower(coalesce(section_heading, '') || ' ' || coalesce(content, '')) gin_trgm_ops); + +create index if not exists document_labels_label_trgm_idx + on public.document_labels using gin (lower(label) gin_trgm_ops); + +create index if not exists document_summaries_summary_trgm_idx + on public.document_summaries using gin (lower(summary) gin_trgm_ops); + +create index if not exists document_table_facts_owner_document_page_idx + on public.document_table_facts(owner_id, document_id, page_number); + +create index if not exists document_index_units_owner_chunk_type_idx + on public.document_index_units(owner_id, source_chunk_id, unit_type) + where source_chunk_id is not null; + +create index if not exists document_pages_document_idx + on public.document_pages(document_id, page_number); + +create index if not exists document_sections_document_idx + on public.document_sections(document_id, section_index); + +create index if not exists rag_retrieval_logs_owner_created_idx + on public.rag_retrieval_logs(owner_id, created_at desc); + +create index if not exists rag_retrieval_logs_miss_idx + on public.rag_retrieval_logs(is_miss, created_at desc) + where is_miss = true; + +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; + 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' + ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + 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; + + 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 63657b132..16ce2230c 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -2396,6 +2396,15 @@ declare 'rag_retrieval_logs_miss_idx', 'rag_retrieval_logs_strategy_idx' ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + 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 @@ -2454,6 +2463,19 @@ begin 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; diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts new file mode 100644 index 000000000..0801c4bd9 --- /dev/null +++ b/tests/public-access-deep.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const publicDocumentId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("public access deep checks", () => { + it("rejects unauthenticated access to operator-only routes", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + const auth = { + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => { + throw new auth.AuthenticationError(); + }), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + }; + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + + const cases = [ + { + routePath: "../src/app/api/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/jobs"), + }, + { + routePath: "../src/app/api/ingestion/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/jobs"), + }, + { + routePath: "../src/app/api/ingestion/batches/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/batches"), + }, + { + routePath: "../src/app/api/documents/bulk/route", + handler: "POST" as const, + request: new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }), + }), + }, + { + routePath: "../src/app/api/documents/[id]/summarize/route", + handler: "POST" as const, + request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }), + params: { id: publicDocumentId }, + }, + { + routePath: "../src/app/api/eval-cases/route", + handler: "POST" as const, + request: new Request("http://localhost/api/eval-cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "What monitoring is needed?", + rating: "good", + answer: "Monitor FBC.", + queryMode: "auto", + queryClass: "table_threshold", + }), + }), + }, + ] as const; + + for (const testCase of cases) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + const mod = await import(testCase.routePath); + const handler = mod[testCase.handler]; + const response = await handler( + testCase.request, + "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined, + ); + expect(response.status, testCase.routePath).toBe(401); + } + }); + + it("does not expose secret values from the health endpoint", async () => { + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key", + OPENAI_API_KEY: "sk-super-secret-openai-key", + }, + isDemoMode: () => false, + })); + const { GET } = await import("../src/app/api/health/route"); + const response = await GET(new Request("https://clinical.example/api/health?deep=1")); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(serialized).not.toContain("super-secret-service-role-key"); + expect(serialized).not.toContain("sk-super-secret-openai-key"); + expect(body.checks.supabaseConfig).toBe("ok"); + expect(body.checks.openaiConfig).toBe("ok"); + }); + +}); + +describe("production anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("fails closed in production when anonymous global retrieval omits owner scope", async () => { + vi.doUnmock("@/lib/rag"); + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "sk-test", + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }), + ).rejects.toThrow(/ownerId|tenant/i); + }); +}); + +describe("test-runtime anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => { + vi.doUnmock("@/lib/rag"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: undefined, + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + in: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const result = await searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }); + + expect(result.results).toEqual([]); + expect(result.telemetry).toBeDefined(); + }); +}); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 68cd98773..6dd16d6c9 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -80,6 +80,10 @@ const registryCatalogPayloadMigration = readFileSync( new URL("../supabase/migrations/20260705030000_registry_catalog_payload.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const searchHealthIndexesMigration = readFileSync( + new URL("../supabase/migrations/20260705180000_reconcile_search_health_indexes.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const ragQueriesRetentionMigration = readFileSync( new URL("../supabase/migrations/20260629060603_rag_queries_retention.sql", import.meta.url), "utf8", @@ -739,6 +743,17 @@ describe("Supabase schema Data API grants", () => { "add column if not exists catalog_payload jsonb not null default '{}'::jsonb", ); }); + + it("reconciles search_schema_health index drift with canonical creates and live aliases", () => { + expect(searchHealthIndexesMigration).toContain("create index if not exists documents_title_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists document_labels_label_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists rag_retrieval_logs_miss_idx"); + expect(searchHealthIndexesMigration).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(searchHealthIndexesMigration).toContain("'documents_title_search_tsv_idx'"); + expect(searchHealthIndexesMigration).toContain("'document_pages_document_id_page_number_key'"); + expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)"); + }); }); describe("RC9 — lexical text path must not fabricate a cosine similarity", () => {