From d350d58a8615f343e7a3dd13f7845c83aadf890a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:06:08 +0800 Subject: [PATCH 1/4] chore: merge design-audit remediation changes --- docs/codebase-index.md | 30 +-- docs/site-map.md | 13 +- playwright.config.ts | 1 + scripts/check-github-action-pins.mjs | 16 +- scripts/generate-site-map.ts | 84 +++---- src/app/layout.tsx | 1 - src/app/page.tsx | 3 - .../master-search-header.tsx | 8 +- src/components/forms/forms-home-page.tsx | 17 +- .../services/services-navigator-page.tsx | 184 ++++++++++---- src/lib/rag-candidate-sources.ts | 101 ++------ src/lib/rag.ts | 128 ++-------- supabase/drift-manifest.json | 55 +---- ...00_patch_rag_and_corrector_scalability.sql | 144 +++++++++++ ...14190000_document_table_facts_trgm_idx.sql | 11 + ...717010000_harden_rag_scalability_patch.sql | 116 +++++++++ supabase/schema.sql | 46 +++- ...audit-content-services-regressions.test.ts | 64 +++-- .../audit-navigation-auth-regressions.test.ts | 19 +- tests/site-map.test.ts | 37 ++- tests/supabase-schema.test.ts | 229 +++++++----------- tests/ui-accessibility.spec.ts | 2 +- tests/ui-smoke.spec.ts | 11 +- tests/ui-tools.spec.ts | 54 ++++- 24 files changed, 829 insertions(+), 545 deletions(-) create mode 100644 supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql create mode 100644 supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql create mode 100644 supabase/migrations/20260717010000_harden_rag_scalability_patch.sql diff --git a/docs/codebase-index.md b/docs/codebase-index.md index a09628bd6..2cfd095e0 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -68,17 +68,19 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### API routes (`src/app/api/`) -| Area | Routes | Entry files | -| ----------- | ----------------------------------------------------------------------- | --------------------------------------------------------------- | -| Answers | `/api/answer`, `/api/answer/stream`, `/api/answer-feedback` | `answer/route.ts`, `answer/stream/route.ts`, `answer-feedback/` | -| Search | `/api/search`, `/api/search/interaction` | `search/` | -| Upload | `/api/upload` | `upload/route.ts` | -| Documents | CRUD, bulk, reindex, labels, search, summarize, table-facts, signed-url | `documents/` | -| Ingestion | batches, jobs, retry, quality | `ingestion/` | -| Registry | records CRUD | `registry/records/` | -| Images | signed URLs | `images/[id]/signed-url/route.ts` | -| Ops | health, setup-status, local-project-id | `health/`, `setup-status/`, `local-project-id/` | -| Eval / jobs | eval cases, job state | `eval-cases/`, `jobs/` | +| Area | Routes | Entry files | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Answers | `/api/answer`, `/api/answer/stream`, `/api/answer-feedback` | `answer/route.ts`, `answer/stream/route.ts`, `answer-feedback/` | +| Search | `/api/search`, `/api/search/interaction`, `/api/search/universal` | `search/` | +| Upload | `/api/upload` | `upload/route.ts` | +| Documents | `/api/documents`, `/api/documents/[id]`, bulk/reindex, labels, reviews, search, signed URLs, summaries, table facts | `documents/` | +| Differentials | `/api/differentials`, `/api/differentials/[slug]`, `/api/differentials/presentations/[slug]` | `differentials/` | +| Medications | `/api/medications`, `/api/medications/[slug]` | `medications/` | +| Ingestion | `/api/ingestion/batches`, `/api/ingestion/jobs`, retry, quality | `ingestion/` | +| Registry | `/api/registry/records`, `/api/registry/records/[slug]` | `registry/records/` | +| Images | `/api/images/[id]/signed-url` | `images/[id]/signed-url/route.ts` | +| Ops | `/api/health`, `/api/health/ready`, `/api/setup-status`, `/api/local-project-id` | `health/`, `setup-status/`, `local-project-id/` | +| Eval / jobs | `/api/eval-cases`, `/api/jobs` | `eval-cases/`, `jobs/` | --- @@ -151,12 +153,12 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map - **CLI:** `supabase/config.toml` — `indexing-v3-agent` function, `verify_jwt = false` - **Schema mirror:** `supabase/schema.sql` (reference; migrations are source of truth) -- **Migrations:** `supabase/migrations/*.sql` (~90 files, May–Jul 2026) +- **Migrations:** `supabase/migrations/*.sql` (chronological source of truth; do not hardcode a count) - **Drift policy:** `docs/supabase-migration-reconciliation.md` -### Core tables +### Schema tables -`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `clinical_registry_records`, `api_rate_limits`, `audit_logs`, `storage_cleanup_jobs` +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `medication_records`, `differential_records`, `source_review_events`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs` **Storage buckets:** `clinical-documents`, `clinical-images` (private) diff --git a/docs/site-map.md b/docs/site-map.md index 3b7516445..986979aba 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -2,7 +2,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current. -## Main product pages +## Main product routes - `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. @@ -799,6 +799,11 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/universal-search-command` - Route discovered from app directory Source: `src/app/mockups/universal-search-command/page.tsx`. - `/mockups/universal-search-redesign` - Route discovered from app directory Source: `src/app/mockups/universal-search-redesign/page.tsx`. +## Public utility route handlers + +- `/auth/callback` - Authentication callback handler. Source: `src/app/auth/callback/route.ts`. +- `/icons/[variant]` - Dynamically generated application icon handler. Source: `src/app/icons/[variant]/route.tsx`. + ## API routes - `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. @@ -838,14 +843,10 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. - `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. -## App route handlers - -- `/auth/callback` - Route discovered from app directory Source: `src/app/auth/callback/route.ts`. - ## Redirects - `/applications` - Redirects to `/tools`. Source: `src/app/applications/route.ts`. -- `/differentials/presentations` - Redirects to `/differentials/presentations/[slug]`. Source: `src/app/differentials/presentations/route.ts`. +- `/differentials/presentations` - Redirects to `/differentials/presentations/[workflow-slug]`. Source: `src/app/differentials/presentations/route.ts`. - `/documents/source` - Redirects to `/documents/search`. Source: `src/app/documents/source/page.tsx`. - `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/route.ts`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. diff --git a/playwright.config.ts b/playwright.config.ts index 268ccd250..aa1b899ee 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -55,6 +55,7 @@ export default defineConfig({ grepInvert: mockupTag, use: { ...devices["Desktop Chrome"], + reducedMotion: "no-preference", ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), }, }, diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 6a82fae7b..82d195306 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { validateActionReference } from "./github-action-pins.mjs"; import { yamlBlock } from "./yaml-contract.mjs"; @@ -10,10 +10,16 @@ const failures = []; const expectedSupabaseCliVersion = "2.108.0"; const expectedSupabaseCliVersionPattern = expectedSupabaseCliVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -for (const fileName of readdirSync(workflowDir) - .filter((name) => /\.ya?ml$/i.test(name)) - .sort()) { - const filePath = path.join(workflowDir, fileName); +function discoverGitHubActionFiles(workflowRoot) { + const workflowDir = path.join(workflowRoot, ".github", "workflows"); + if (!existsSync(workflowDir)) return []; + return readdirSync(workflowDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) + .map((entry) => path.join(workflowDir, entry.name)); +} + +for (const filePath of discoverGitHubActionFiles(process.cwd())) { + const fileName = path.relative(process.cwd(), filePath).replaceAll("\\", "/"); const lines = readFileSync(filePath, "utf8").split(/\r?\n/); lines.forEach((line, index) => { diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 16b019bd8..1927fafa5 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -16,7 +16,7 @@ const appDir = path.join(process.cwd(), "src", "app"); const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); const medicationSlugs = ["acamprosate"] as const; -type RouteKind = "page" | "api"; +type RouteKind = "page" | "handler"; type DiscoveredRoute = { route: string; @@ -31,14 +31,24 @@ type RedirectRoute = { type SiteMapData = { pageRoutes: DiscoveredRoute[]; + publicRouteHandlers: DiscoveredRoute[]; apiRoutes: DiscoveredRoute[]; appRouteHandlers: DiscoveredRoute[]; redirects: RedirectRoute[]; nonRoutedMockupArtifacts: string[]; }; +const productRouteHandlerPaths = new Set(["/applications", "/differentials/presentations", "/medications"]); + +const documentedRedirectTargets: Record = { + "/applications": "/tools", + "/differentials/presentations": "/differentials/presentations/[workflow-slug]", + "/medications": "/?mode=prescribing", +}; + const routeDescriptions: Record = { "/": "Main Clinical KB shell.", + "/applications": "Legacy application launcher redirect to Tools.", "/differentials": "Differentials home and search surface.", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", @@ -72,6 +82,11 @@ const routeDescriptions: Record = { "/specifiers/map": "Psychiatric specifier family map.", }; +const publicRouteHandlerDescriptions: Record = { + "/auth/callback": "Authentication callback handler.", + "/icons/[variant]": "Dynamically generated application icon handler.", +}; + const apiDescriptions: Record = { "/api/answer": "Generate answer response.", "/api/answer/stream": "Streaming answer response.", @@ -128,8 +143,16 @@ function routeSegment(segment: string) { return segment; } +function isApiRoute(route: string) { + return route === "/api" || route.startsWith("/api/"); +} + function fileToRoute(filePath: string, kind: RouteKind) { - const suffix = kind === "page" ? "page.tsx" : "route.ts"; + const suffix = path.basename(filePath); + const expectedSuffixes = kind === "page" ? ["page.tsx"] : ["route.ts", "route.tsx"]; + if (!expectedSuffixes.includes(suffix)) { + throw new Error(`Unsupported ${kind} route file: ${filePath}`); + } const relative = toPosixPath(path.relative(appDir, filePath)); const withoutFile = relative.slice(0, -suffix.length).replace(/\/$/, ""); const segments = withoutFile.split("/").filter(Boolean).map(routeSegment).filter(Boolean); @@ -150,8 +173,9 @@ function collectFiles(root: string, targetFileName: string): string[] { } function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { - const targetFile = kind === "page" ? "page.tsx" : "route.ts"; - return collectFiles(appDir, targetFile) + const targetFiles = kind === "page" ? ["page.tsx"] : ["route.ts", "route.tsx"]; + return targetFiles + .flatMap((targetFile) => collectFiles(appDir, targetFile)) .map((file) => ({ route: fileToRoute(file, kind), file: toPosixPath(path.relative(process.cwd(), file)), @@ -159,34 +183,12 @@ function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); } -function isApiRoute(route: string) { - return route === "/api" || route.startsWith("/api/"); -} - -function extractRedirectTarget(source: string): string | null { - const pageRedirect = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; - if (pageRedirect) return pageRedirect; - - const urlRedirect = source.match(/NextResponse\.redirect\(\s*new URL\(\s*["']([^"']+)["']/)?.[1]; - if (urlRedirect) return urlRedirect; - - const pathnameRedirect = source.match(/\.pathname\s*=\s*["']([^"']+)["']/)?.[1]; - if (pathnameRedirect) return pathnameRedirect; - - // Template-literal destination builders with a stable route prefix. - const presentationsRedirect = source.match(/`(\/differentials\/presentations\/)\$\{/)?.[1]; - if (presentationsRedirect && source.includes("NextResponse.redirect")) { - return `${presentationsRedirect}[slug]`; - } - - return null; -} - function discoverRedirects(routes: DiscoveredRoute[]): RedirectRoute[] { return routes .map((route) => { const source = readFileSync(path.join(process.cwd(), route.file), "utf8"); - const target = extractRedirectTarget(source); + const target = + documentedRedirectTargets[route.route] ?? source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; return target ? { ...route, target } : null; }) .filter((value): value is RedirectRoute => Boolean(value)) @@ -203,20 +205,13 @@ function discoverNonRoutedMockupArtifacts() { export function collectSiteMapData(): SiteMapData { const pageRoutes = discoverRoutes("page"); - const routeHandlers = discoverRoutes("api"); - const apiRoutes = routeHandlers.filter((route) => isApiRoute(route.route)); - const nonApiHandlers = routeHandlers.filter((route) => !isApiRoute(route.route)); - const redirects = [...discoverRedirects(pageRoutes), ...discoverRedirects(nonApiHandlers)].sort( - (left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file), - ); - const redirectRoutes = new Set(redirects.map((redirect) => redirect.route)); - const appRouteHandlers = nonApiHandlers.filter((route) => !redirectRoutes.has(route.route)); - + const routeHandlers = discoverRoutes("handler"); + const publicRouteHandlers = routeHandlers.filter((route) => !isApiRoute(route.route)); return { pageRoutes, - apiRoutes, - appRouteHandlers, - redirects, + publicRouteHandlers, + apiRoutes: routeHandlers.filter((route) => isApiRoute(route.route)), + redirects: discoverRedirects([...pageRoutes, ...publicRouteHandlers]), nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), }; } @@ -397,6 +392,9 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ].includes(route.route), ); const mockupRoutes = data.pageRoutes.filter((route) => route.route.startsWith("/mockups")); + const publicUtilityRouteHandlers = data.publicRouteHandlers.filter( + (route) => !productRouteHandlerPaths.has(route.route), + ); const lines = [ "# Clinical KB Site Map", @@ -404,7 +402,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current.", "", ...section( - "Main product pages", + "Main product routes", productRoutes.map((route) => routeLine(route, routeDescriptions)), ), ...section("Mode/query routes", renderModeRoutes()), @@ -481,6 +479,10 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ] : []), ]), + ...section( + "Public utility route handlers", + publicUtilityRouteHandlers.map((route) => routeLine(route, publicRouteHandlerDescriptions)), + ), ...section( "API routes", data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 61fa12ce0..2ae175ea9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; -import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { AuthProvider } from "@/lib/supabase/client"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; import { resolveMetadataBase } from "@/lib/metadata-base"; diff --git a/src/app/page.tsx b/src/app/page.tsx index 5dc55d31b..6ce30c137 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,9 +7,6 @@ import { appModeHomeHref, isAppModeId, isAppModeVisible, type AppModeId } from " type HomeProps = { searchParams?: Promise<{ mode?: string | string[]; - q?: string | string[]; - focus?: string | string[]; - run?: string | string[]; }>; }; diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 099cb89ba..665986618 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -1581,13 +1581,7 @@ export function MasterSearchHeader({ onBlur={(event) => { const nextFocusedElement = event.relatedTarget; if (nextFocusedElement instanceof Node && event.currentTarget.contains(nextFocusedElement)) return; - // Defer dismiss so a pointer activation on a menuitem can land before - // unmount; keyboard leave (Tab/Shift+Tab) still closes on the next frame. - const menuRoot = event.currentTarget; - window.requestAnimationFrame(() => { - if (menuRoot.contains(document.activeElement)) return; - setModeMenuOpen(false); - }); + setModeMenuOpen(false); }} className={cn("relative z-[60] min-w-0", isWorkflowHeader ? "justify-self-start" : "justify-self-center")} > diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index 3b01a4091..b53a98b10 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -6,7 +6,6 @@ import { FileQuestion, FileText, Loader2, - Route, Search, ShieldAlert, ShieldCheck, @@ -30,22 +29,22 @@ import { countVerifiedRegistryRecords, useRegistryRecords } from "@/lib/use-regi const taskCards: ModeHomeAction[] = [ { title: "Find a form", - description: "Number, pathway, clock, keyword.", + description: "Title, purpose, or workflow detail.", icon: Search, href: appModeHomeHref("forms", { focus: true }), }, { title: "Readiness checks", - description: "Maker, clock, copies, source.", + description: "Review status, source, and local confirmation.", icon: ClipboardCheck, href: `/forms/${defaultFormSlug() ?? ""}`, }, { - title: "Browse pathways", - description: "Before, current, parallel, after.", - icon: Route, + title: "Check source status", + description: "Find records that still need local confirmation.", + icon: ShieldAlert, href: appModeHomeHref("forms", { - query: "forms pathway before current parallel after", + query: "local confirmation required", focus: true, run: true, }), @@ -91,9 +90,7 @@ export function FormsHomePage() { ) : registry.status === "error" ? ( { - for (const criterion of service.criteria ?? []) { - if (criterion.tone === "meet") total.meets += 1; - if (criterion.tone === "caution") total.cautions += 1; - if (criterion.tone === "reject") total.rejects += 1; - } - const confidence = service.verification?.confidence ?? "Unknown"; - if (confidence === "High") total.high += 1; - else if (confidence === "Medium") total.medium += 1; - else if (confidence === "Low") total.low += 1; - else total.unknown += 1; - if (service.verification?.locallyVerified || (service.source?.status ?? "").toLowerCase().includes("source")) { - total.verified += 1; - } - if ((service.source?.status ?? "").toLowerCase().includes("confirmation")) total.localConfirmation += 1; - return total; - }, - { meets: 0, cautions: 0, rejects: 0, high: 0, medium: 0, low: 0, unknown: 0, verified: 0, localConfirmation: 0 }, - ); -} - function Stepper() { return (
@@ -220,7 +197,7 @@ function ServiceCard({ @@ -339,7 +341,8 @@ function RightRail({ + Edit via result controls
{rows.map(([label, count, Icon, color]) => ( @@ -380,27 +378,61 @@ function RightRail({ ))}
+ {checklistExpanded ? ( +
+ {selected.map((service) => ( +
+

{service.title}

+
    + {(service.criteria ?? []).map((criterion) => ( +
  • • {criterion.label}
  • + ))} +
+
+ ))} +
+ ) : null}

Source confidence

-
- - - - +
+ {confidenceTotal > 0 ? ( + <> + {counts.high ? : null} + {counts.medium ? ( + + ) : null} + {counts.low ? : null} + {counts.unknown ? ( + + ) : null} + + ) : null}
@@ -424,15 +456,68 @@ function RightRail({ {counts.unknown}
+ {confidenceExpanded ? ( +
+ {matches.slice(0, 8).map((service) => ( +
+ {service.title} + + {service.verification?.confidence ?? "Unknown"} + +
+ ))} + {matches.length > 8 ? ( +

+{matches.length - 8} more results

+ ) : null} +
+ ) : null}
+ {comparisonExpanded ? ( +
+ {selected.map((service) => ( +
+
+

{service.title}

+ + Open + +
+
+ {[ + ["Contact", text(service.primaryContact?.value)], + ["Eligibility", text(service.eligibility, "Eligibility pending")], + ["Cost", text(service.cost, "Cost pending")], + ["Source", text(service.source?.status, "Source pending")], + ["Confidence", text(service.verification?.confidence, "Unknown")], + ].map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ))} +
+ ) : null} ); } @@ -520,6 +605,7 @@ export function ServicesNavigatorPage() { } sidebar={ setSelectedSlugs([])} diff --git a/src/lib/rag-candidate-sources.ts b/src/lib/rag-candidate-sources.ts index dd10ade39..effed08f0 100644 --- a/src/lib/rag-candidate-sources.ts +++ b/src/lib/rag-candidate-sources.ts @@ -45,26 +45,6 @@ import type { DocumentIndexUnitMatch, DocumentMemoryCard, SearchResult } from "@ // where telemetry is in scope, record the failing RPC + code so it shows up in rag_retrieval_logs. export type SupabaseRpcError = { message?: string; code?: string; details?: string; hint?: string } | null; -type AbortableQuery = PromiseLike & { - abortSignal?: (signal: AbortSignal) => PromiseLike; -}; - -function abortReason(signal: AbortSignal): Error { - return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); -} - -function throwIfAborted(signal?: AbortSignal) { - if (signal?.aborted) throw abortReason(signal); -} - -async function resolveQuery(query: AbortableQuery, signal?: AbortSignal): Promise { - throwIfAborted(signal); - const pending = signal && typeof query.abortSignal === "function" ? query.abortSignal(signal) : query; - const result = await pending; - throwIfAborted(signal); - return result; -} - function legacyRankFields(versionedName: string) { if (versionedName === "match_document_chunks_v2") return ["similarity"]; if (versionedName === "match_documents_for_query_v2") return ["text_rank"]; @@ -101,17 +81,15 @@ export async function callVersionedRetrievalRpc versionedName: string, legacyName: string, args: Record, - signal?: AbortSignal, ): Promise<{ data: T | null; error: SupabaseRpcError }> { - type RpcResult = { data: T | null; error: SupabaseRpcError }; const client = supabase as unknown as { - rpc: (name: string, rpcArgs: Record) => AbortableQuery; + rpc: (name: string, rpcArgs: Record) => Promise<{ data: T | null; error: SupabaseRpcError }>; }; - const versioned = await resolveQuery(client.rpc(versionedName, args), signal); + const versioned = await client.rpc(versionedName, args); if (versioned && !isMissingRetrievalRpcError(versioned.error)) return versioned; const legacyArgs = { ...args }; delete legacyArgs.include_public; - const ownerResult = await resolveQuery(client.rpc(legacyName, legacyArgs), signal); + const ownerResult = await client.rpc(legacyName, legacyArgs); const ownerFilter = String(args.owner_filter ?? ""); if ( ownerResult.error || @@ -121,13 +99,10 @@ export async function callVersionedRetrievalRpc ) { return ownerResult; } - const publicResult = await resolveQuery( - client.rpc(legacyName, { - ...legacyArgs, - owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, - }), - signal, - ); + const publicResult = await client.rpc(legacyName, { + ...legacyArgs, + owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, + }); if (publicResult.error) return publicResult; return { data: mergeLegacyAccessRows( @@ -204,7 +179,6 @@ export async function searchTextChunkCandidates(args: { allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; - signal?: AbortSignal; }) { const runChunkText = async (queryText: string, matchCount: number) => { const accessScope = retrievalAccessScopeForArgs(args); @@ -218,7 +192,6 @@ export async function searchTextChunkCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(accessScope), }, - args.signal, ); // Report the error before returning empty so a schema drift on this // most-terminal lexical layer surfaces in hybrid_rpc_errors telemetry @@ -275,13 +248,10 @@ export async function searchTextChunkCandidates(args: { const primary = variants[0] ?? ""; let effectivePrimary = primary; if (primary) { - const { data: corrected } = await resolveQuery( - args.supabase.rpc("correct_clinical_query_terms", { - input_query: primary, - min_sim: 0.45, - }), - args.signal, - ); + const { data: corrected } = await args.supabase.rpc("correct_clinical_query_terms", { + input_query: primary, + min_sim: 0.45, + }); if (typeof corrected === "string" && corrected && corrected !== primary) { const correctedResults = await runChunkText(corrected, args.matchCount); if (correctedResults.length > 0) return correctedResults; @@ -406,7 +376,6 @@ async function fetchBestDocumentLookupChunks(args: { ownerId?: string; accessScope?: RetrievalAccessScope; allowGlobalSearch?: boolean; - signal?: AbortSignal; }) { const terms = documentLookupChunkTerms(args.query); const { data: rpcChunks, error: rpcError } = await callVersionedRetrievalRpc( @@ -419,7 +388,6 @@ async function fetchBestDocumentLookupChunks(args: { match_count: Math.max(args.limit * 3, 24), ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); if (!rpcError && rpcChunks?.length) { const ranked = (rpcChunks as DocumentLookupChunkRow[]) @@ -450,8 +418,9 @@ async function fetchBestDocumentLookupChunks(args: { .in("document_id", args.documentIds) .limit(Math.max(args.limit * 4, 24)); - const matchedQuery = safeFilters ? baseQuery.or(safeFilters) : baseQuery.order("chunk_index", { ascending: true }); - const { data: matchedChunks, error: matchedError } = await resolveQuery(matchedQuery, args.signal); + const { data: matchedChunks, error: matchedError } = safeFilters + ? await baseQuery.or(safeFilters) + : await baseQuery.order("chunk_index", { ascending: true }); if (!matchedError && matchedChunks?.length) { const ranked = (matchedChunks as DocumentLookupChunkRow[]) @@ -470,7 +439,7 @@ async function fetchBestDocumentLookupChunks(args: { .in("document_id", args.documentIds) .order("chunk_index", { ascending: true }) .limit(args.limit); - const { data: fallbackChunks, error: fallbackError } = await resolveQuery(fallbackQuery, args.signal); + const { data: fallbackChunks, error: fallbackError } = await fallbackQuery; if (fallbackError || !fallbackChunks?.length) return { chunks: [] as DocumentLookupChunkRow[], terms }; return { chunks: fallbackChunks as DocumentLookupChunkRow[], terms }; } @@ -482,7 +451,6 @@ async function fetchDocumentTitleAliasRows(args: { ownerId?: string; accessScope?: RetrievalAccessScope; documentIds?: string[]; - signal?: AbortSignal; }) { const terms = analyzeClinicalQuery(args.query) .documentTitleTerms.map((term) => term.replace(/[%_,]/g, " ").replace(/\s+/g, " ").trim()) @@ -506,7 +474,7 @@ async function fetchDocumentTitleAliasRows(args: { } if (args.documentIds?.length) query = query.in("id", args.documentIds); - const { data, error } = await resolveQuery(query, args.signal); + const { data, error } = await query; if (error || !data?.length) return [] as DocumentLookupRow[]; return (data as DocumentLookupRow[]).map((document) => ({ @@ -531,7 +499,6 @@ export async function searchDocumentLookupFastPath(args: { documentIds?: string[]; matchCount: number; telemetry?: SearchTelemetry; - signal?: AbortSignal; }): Promise { if (!args.ownerId) return [] as SearchResult[]; const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( @@ -548,7 +515,6 @@ export async function searchDocumentLookupFastPath(args: { match_count: matchCount, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_documents_for_query", error); if (error || !data?.length) return [] as DocumentLookupRow[]; @@ -567,7 +533,6 @@ export async function searchDocumentLookupFastPath(args: { ownerId: args.ownerId, accessScope: args.accessScope, documentIds: args.documentIds, - signal: args.signal, }); const documentsById = new Map(); for (const document of [...titleAliasDocuments, ...documentSets.flat()]) { @@ -601,7 +566,6 @@ export async function searchDocumentLookupFastPath(args: { limit: Math.max(args.matchCount, rankedDocuments.length * 4), ownerId: args.ownerId, accessScope: args.accessScope, - signal: args.signal, }); if (!chunks.length) return []; @@ -657,7 +621,6 @@ export async function loadChunksForMemoryCards( supabase: ReturnType, cards: DocumentMemoryCard[], accessScope: RetrievalAccessScope, - signal?: AbortSignal, ) { const documentIds = Array.from(new Set(cards.map((card) => card.document_id))).slice(0, 80); if (documentIds.length === 0) return [] as SearchResult[]; @@ -673,7 +636,7 @@ export async function loadChunksForMemoryCards( } else { documentQuery = documentQuery.is("owner_id", null); } - const { data: documents, error: documentsError } = await resolveQuery(documentQuery, signal); + const { data: documents, error: documentsError } = await documentQuery; if (documentsError || !documents?.length) return [] as SearchResult[]; const documentById = new Map(documents.map((document) => [document.id, document])); @@ -684,7 +647,7 @@ export async function loadChunksForMemoryCards( ), ).slice(0, 80); if (chunkIds.length === 0) return [] as SearchResult[]; - const chunksQuery = supabase + const { data: chunks, error: chunksError } = await supabase .from("document_chunks") .select( "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id", @@ -692,7 +655,6 @@ export async function loadChunksForMemoryCards( .in("id", chunkIds) .in("document_id", [...allowedDocumentIds]) .limit(chunkIds.length); - const { data: chunks, error: chunksError } = await resolveQuery(chunksQuery, signal); if (chunksError || !chunks?.length) return [] as SearchResult[]; const bestCardByChunk = new Map(); for (const card of cards) { @@ -821,7 +783,6 @@ export async function loadChunksForSignalMatches(args: { ownerId?: string; accessScope?: RetrievalAccessScope; cache?: ChunkLoadCache; - signal?: AbortSignal; }) { const bestMatchByChunk = new Map(); for (const match of args.matches) { @@ -840,12 +801,11 @@ export async function loadChunksForSignalMatches(args: { ids: chunkIds, scopeKey: cacheScopeKey, fetchRows: async (missingChunkIds) => { - const query = args.supabase + const { data, error } = await args.supabase .from("document_chunks") .select("id,document_id") .in("id", missingChunkIds) .limit(missingChunkIds.length); - const { data, error } = await resolveQuery(query, args.signal); return { data: data as ChunkScopeRow[] | null, error }; }, }); @@ -870,7 +830,7 @@ export async function loadChunksForSignalMatches(args: { } else { documentQuery = documentQuery.is("owner_id", null); } - const { data, error } = await resolveQuery(documentQuery, args.signal); + const { data, error } = await documentQuery; return { data: data as HydratedDocumentRow[] | null, error }; }, }); @@ -889,7 +849,7 @@ export async function loadChunksForSignalMatches(args: { ids: allowedChunkIds, scopeKey: cacheScopeKey, fetchRows: async (missingAllowedChunkIds) => { - const query = args.supabase + const { data, error } = await args.supabase .from("document_chunks") .select( "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id", @@ -897,7 +857,6 @@ export async function loadChunksForSignalMatches(args: { .in("id", missingAllowedChunkIds) .in("document_id", [...allowedDocumentIds]) .limit(missingAllowedChunkIds.length); - const { data, error } = await resolveQuery(query, args.signal); return { data: data as HydratedChunkRow[] | null, error }; }, }); @@ -971,7 +930,6 @@ export async function searchTableFactCandidates(args: { matchCount: number; telemetry?: SearchTelemetry; cache?: ChunkLoadCache; - signal?: AbortSignal; }) { const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( 0, @@ -988,7 +946,6 @@ export async function searchTableFactCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_document_table_facts_text", error); if (error || !data?.length) return [] as TableFactRpcRow[]; @@ -1030,7 +987,6 @@ export async function searchTableFactCandidates(args: { ownerId: args.ownerId, accessScope: args.accessScope, cache: args.cache, - signal: args.signal, }); } @@ -1046,7 +1002,6 @@ export async function searchEmbeddingFieldCandidates(args: { matchCount: number; telemetry?: SearchTelemetry; cache?: ChunkLoadCache; - signal?: AbortSignal; }) { const { data, error } = await callVersionedRetrievalRpc( args.supabase, @@ -1060,7 +1015,6 @@ export async function searchEmbeddingFieldCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -1098,7 +1052,6 @@ export async function searchEmbeddingFieldCandidates(args: { ownerId: args.ownerId, accessScope: args.accessScope, cache: args.cache, - signal: args.signal, }); } @@ -1114,7 +1067,6 @@ export async function searchIndexUnitCandidates(args: { matchCount: number; telemetry?: SearchTelemetry; cache?: ChunkLoadCache; - signal?: AbortSignal; }) { const { data, error } = await callVersionedRetrievalRpc( args.supabase, @@ -1128,7 +1080,6 @@ export async function searchIndexUnitCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -1168,7 +1119,6 @@ export async function searchIndexUnitCandidates(args: { ownerId: args.ownerId, accessScope: args.accessScope, cache: args.cache, - signal: args.signal, }); } @@ -1185,7 +1135,6 @@ export async function withMemoryBoostedCandidates(args: { documentIds?: string[]; matchCount: number; cardCache?: MemoryCardCache; - signal?: AbortSignal; }) { // A3: the memory-card fetch is invoked at several waterfall stages. Memoize per request, // scoped by owner/document filters because fetchMemoryCardsForQuery applies those filters. @@ -1208,19 +1157,13 @@ export async function withMemoryBoostedCandidates(args: { accessScope: args.accessScope, documentIds: args.documentIds, matchCount: effectiveMatchCount, - signal: args.signal, }); args.cardCache?.set(cacheKey, cardsPromise); } const cards = await cardsPromise; if (cards.length === 0) return { results: args.candidates, cards }; - const memoryChunkResults = await loadChunksForMemoryCards( - args.supabase, - cards, - retrievalAccessScopeForArgs(args), - args.signal, - ); + const memoryChunkResults = await loadChunksForMemoryCards(args.supabase, cards, retrievalAccessScopeForArgs(args)); const merged = mergeSearchResults(memoryChunkResults, args.candidates); return { results: applyMemoryCardBoosts(args.query, merged, cards), diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 2b689e5b3..0c2a8bbaf 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -417,30 +417,9 @@ const confidenceOrder = { } as const; /** Throw if aborted. */ -function abortReason(signal: AbortSignal): Error { - return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); -} - function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) { - throw abortReason(signal); - } -} - -async function awaitWithAbortSignal(pending: Promise, signal?: AbortSignal): Promise { - if (!signal) return pending; - throwIfAborted(signal); - - let onAbort: (() => void) | undefined; - const aborted = new Promise((_resolve, reject) => { - onAbort = () => reject(abortReason(signal)); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - }); - try { - return await Promise.race([pending, aborted]); - } finally { - if (onAbort) signal.removeEventListener("abort", onAbort); + throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); } } @@ -1311,8 +1290,6 @@ export async function analyzeQueryWithClassifierFallback( // owner_filter retrieval will use so grounding can never see documents retrieval cannot. corpusGrounding?: { supabase: ReturnType; ownerFilter: string | null }; ownerId?: string | null; - signal?: AbortSignal; - skipClassifier?: boolean; }, ) { if ( @@ -1341,7 +1318,6 @@ export async function analyzeQueryWithClassifierFallback( supabase: opts.corpusGrounding.supabase, query, ownerFilter: opts.corpusGrounding.ownerFilter, - signal: opts.signal, }); if (grounding.verdict === "in_corpus_topic") { return { @@ -1367,8 +1343,7 @@ export async function analyzeQueryWithClassifierFallback( analysis = { ...analysis, corpusGrounding: "inconclusive" }; } - if (!analysis.needsClassifierFallback || !env.OPENAI_API_KEY || opts?.skipClassifier) return analysis; - throwIfAborted(opts?.signal); + if (!analysis.needsClassifierFallback || !env.OPENAI_API_KEY) return analysis; const memoKey = classifierVerdictMemoKey(query, analysis); const memoized = classifierVerdictMemo.get(memoKey); @@ -1386,11 +1361,10 @@ export async function analyzeQueryWithClassifierFallback( } try { - const verdict = await awaitWithAbortSignal(pending, opts?.signal); + const verdict = await pending; storeClassifierVerdictMemo(memoKey, verdict); return applyClassifierVerdict(analysis, verdict); } catch { - if (opts?.signal?.aborted) throw abortReason(opts.signal); // Transport/parse failures are deliberately NOT memoized: fall back to the deterministic // analysis for this request only, and let the next request retry the classifier. return analysis; @@ -1539,7 +1513,6 @@ export async function attachDocumentRankingMetadata( results: SearchResult[], ownerId?: string, cache = createDocumentRankingMetadataCache(), - signal?: AbortSignal, ) { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); if (documentIds.length === 0) return results; @@ -1569,7 +1542,7 @@ export async function attachDocumentRankingMetadata( document_summary: metadata.summary, }; }); - return attachIndexQualityMetadata(supabase, enriched, ownerId, cache, signal); + return attachIndexQualityMetadata(supabase, enriched, ownerId, cache); } const [metadataRows, indexedResults] = await Promise.all([ @@ -1577,12 +1550,8 @@ export async function attachDocumentRankingMetadata( supabase, ownerId, documentIds: missingDocumentIds, - signal, - }).catch(() => { - if (signal?.aborted) throw abortReason(signal); - return null; - }), - attachIndexQualityMetadata(supabase, results, ownerId, cache, signal), + }).catch(() => null), + attachIndexQualityMetadata(supabase, results, ownerId, cache), ]); if (!metadataRows) return indexedResults; @@ -1619,7 +1588,6 @@ async function attachIndexQualityMetadata( results: SearchResult[], ownerId?: string, cache = createDocumentRankingMetadataCache(), - signal?: AbortSignal, ): Promise { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); if (documentIds.length === 0) return results; @@ -1631,15 +1599,12 @@ async function attachIndexQualityMetadata( .select("document_id,owner_id,quality_score,extraction_quality,metrics,issues,updated_at") .in("document_id", missingDocumentIds); if (ownerId) query = query.eq("owner_id", ownerId); - if (signal) query = query.abortSignal(signal); const { data, error } = await query; - throwIfAborted(signal); if (error) return results; for (const documentId of missingDocumentIds) cache.indexQuality.set(documentId, null); for (const row of data ?? []) cache.indexQuality.set(row.document_id, row as SearchResult["indexing_quality"]); return withCachedIndexQuality(results, cache); } catch { - if (signal?.aborted) throw abortReason(signal); return results; } } @@ -1648,7 +1613,6 @@ async function attachIndexQualityMetadata( export async function attachPageVisualEvidence( supabase: ReturnType, results: SearchResult[], - signal?: AbortSignal, ): Promise { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); const pageNumbers = Array.from( @@ -1668,7 +1632,7 @@ export async function attachPageVisualEvidence( const selectColumns = "id,document_id,page_number,storage_path,caption,bbox,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata"; - const pageQuery = + const [pageData, directData] = await Promise.all([ pageNumbers.length > 0 ? supabase .from("document_images") @@ -1679,8 +1643,7 @@ export async function attachPageVisualEvidence( .neq("image_type", "logo_decorative") .order("clinical_relevance_score", { ascending: false }) .limit(80) - : null; - const directQuery = + : Promise.resolve({ data: [], error: null }), sourceImageIds.length > 0 ? supabase .from("document_images") @@ -1689,20 +1652,8 @@ export async function attachPageVisualEvidence( .eq("searchable", true) .neq("image_type", "logo_decorative") .limit(sourceImageIds.length) - : null; - const [pageData, directData] = await Promise.all([ - pageQuery - ? signal && typeof pageQuery.abortSignal === "function" - ? pageQuery.abortSignal(signal) - : pageQuery - : Promise.resolve({ data: [], error: null }), - directQuery - ? signal && typeof directQuery.abortSignal === "function" - ? directQuery.abortSignal(signal) - : directQuery : Promise.resolve({ data: [], error: null }), ]); - throwIfAborted(signal); const data = [...(pageData.data ?? []), ...(directData.data ?? [])]; if ((pageData.error && directData.error) || data.length === 0) return results; @@ -2274,8 +2225,6 @@ async function prepareCoverageGateResults(args: { queryClass: RagQueryClass; telemetry: SearchTelemetry; metadataCache: DocumentRankingMetadataCache; - includeVisualEvidence?: boolean; - signal?: AbortSignal; }) { const startedAt = Date.now(); const candidates = await attachDocumentRankingMetadata( @@ -2283,20 +2232,18 @@ async function prepareCoverageGateResults(args: { args.candidates, args.ownerId, args.metadataCache, - args.signal, ); - const rankedResults = selectRankedRetrievalResults({ - query: args.query, - queryClass: args.queryClass, - candidates, - topK: args.topK, - maxResultsPerDocument: args.maxResultsPerDocument, - telemetry: args.telemetry, - }); - let results = - args.includeVisualEvidence === false - ? rankedResults - : await attachPageVisualEvidence(args.supabase, rankedResults, args.signal); + let results = await attachPageVisualEvidence( + args.supabase, + selectRankedRetrievalResults({ + query: args.query, + queryClass: args.queryClass, + candidates, + topK: args.topK, + maxResultsPerDocument: args.maxResultsPerDocument, + telemetry: args.telemetry, + }), + ); results = applySecondStageRerankIfNeeded({ queryClass: args.queryClass, results, @@ -2415,8 +2362,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { } const indexingVersionAtRetrievalStart = await cacheIndexingVersion(args, { forceRefresh: true }); const supabase = createAdminClient(); - const attachSearchVisualEvidence = (results: SearchResult[]) => - args.lexicalOnly ? Promise.resolve(results) : attachPageVisualEvidence(supabase, results, args.signal); // When the provider is source-only (offline mode, or auto mode without a usable key) we must // never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only. const sourceOnlyRetrieval = isSourceOnlyMode(); @@ -2453,8 +2398,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const queryAnalysis = await analyzeQueryWithClassifierFallback(retrievalQuery, analyzeClinicalQuery(retrievalQuery), { corpusGrounding: corpusGroundingScope, ownerId: args.ownerId, - signal: args.signal, - skipClassifier: args.lexicalOnly, }); throwIfAborted(args.signal); if (modeQueryClass) queryAnalysis.queryClass = modeQueryClass; @@ -2466,7 +2409,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const telemetry = createSearchTelemetry(retrievalQuery, queryClassification.queryClass); if (queryAnalysis.corpusGrounding) telemetry.corpus_grounding = queryAnalysis.corpusGrounding; - const ragAliases = await fetchEnabledRagAliases(supabase, args.ownerId, args.accessScope, args.signal); + const ragAliases = await fetchEnabledRagAliases(supabase, args.ownerId, args.accessScope); const ragAliasExpansions = selectRagAliasExpansions(retrievalQuery, ragAliases); telemetry.rag_alias_count = ragAliases.length; telemetry.rag_alias_expansion_count = ragAliasExpansions.length; @@ -2506,13 +2449,10 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { // in searchTextChunkCandidates). Only reached for would-be-unsupported queries, so it adds no // hot-path cost; `typoCorrected` guards against recursion. if (!args.typoCorrected && !sourceOnlyRetrieval) { - let correctionQuery = supabase.rpc("correct_clinical_query_terms", { + const { data: corrected } = await supabase.rpc("correct_clinical_query_terms", { input_query: retrievalQuery, min_sim: 0.45, }); - if (args.signal) correctionQuery = correctionQuery.abortSignal(args.signal); - const { data: corrected } = await correctionQuery; - throwIfAborted(args.signal); if (typeof corrected === "string" && corrected && corrected.toLowerCase() !== retrievalQuery.toLowerCase()) { return searchChunksWithTelemetry({ ...args, query: corrected, typoCorrected: true }); } @@ -2547,7 +2487,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { allowGlobalSearch: args.allowGlobalSearch, matchCount: textCandidateCount, telemetry, - signal: args.signal, }); telemetry.text_candidate_count = textData.length; telemetry.text_fast_path_latency_ms = Date.now() - textRpcStartedAt; @@ -2565,7 +2504,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { textData as SearchResult[], args.ownerId, documentRankingMetadataCache, - args.signal, ); expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, textCandidates); const baseTextResults = selectRankedRetrievalResults({ @@ -2579,7 +2517,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const baseTextFastPath = decideTextFastPath(args.query, baseTextResults, queryClassification.queryClass); if (!args.forceEmbedding && shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) { - textFastResults = await attachSearchVisualEvidence(baseTextResults); + textFastResults = await attachPageVisualEvidence(supabase, baseTextResults); textFastResults = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, results: textFastResults, @@ -2603,7 +2541,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, - signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2621,7 +2558,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }); - textFastResults = await attachSearchVisualEvidence(textFastResults); + textFastResults = await attachPageVisualEvidence(supabase, textFastResults); textFastResults = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, results: textFastResults, @@ -2656,7 +2593,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { matchCount: Math.min(candidateCount, 48), telemetry, cache: chunkLoadCache, - signal: args.signal, }); const tableFactLatencyMs = Date.now() - tableFactStartedAt; telemetry.supabase_rpc_latency_ms += tableFactLatencyMs; @@ -2680,7 +2616,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, telemetry, - signal: args.signal, }); const documentLookupLatencyMs = Date.now() - documentLookupStartedAt; telemetry.supabase_rpc_latency_ms += documentLookupLatencyMs; @@ -2696,7 +2631,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { mergeSearchResults(documentLookupData, textFastResults), args.ownerId, documentRankingMetadataCache, - args.signal, ); expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, documentLookupCandidates); const memoryBoost = await withMemoryBoostedCandidates({ @@ -2708,7 +2642,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, - signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2723,7 +2656,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { topScore: Math.max(telemetry.memory_top_score ?? 0, ...memoryBoost.cards.map(memoryCardChunkScore)), }, ); - let documentLookupResults = await attachSearchVisualEvidence( + let documentLookupResults = await attachPageVisualEvidence( + supabase, selectRankedRetrievalResults({ query: retrievalQuery, queryClass: queryClassification.queryClass, @@ -2773,8 +2707,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryClass: queryClassification.queryClass, telemetry, metadataCache: documentRankingMetadataCache, - includeVisualEvidence: !args.lexicalOnly, - signal: args.signal, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); @@ -2848,7 +2780,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { matchCount: Math.min(candidateCount, 48), telemetry, cache: chunkLoadCache, - signal: args.signal, }); return { candidates, latencyMs: Date.now() - startedAt }; })(), @@ -2865,7 +2796,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { matchCount: Math.min(candidateCount, 64), telemetry, cache: chunkLoadCache, - signal: args.signal, }); return { candidates, latencyMs: Date.now() - startedAt }; })(), @@ -2883,7 +2813,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filters: documentFilterList ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -2934,7 +2863,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { merged, args.ownerId, documentRankingMetadataCache, - args.signal, ); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -2946,7 +2874,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, - signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2963,7 +2890,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }), - args.signal, ); results = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, @@ -2994,7 +2920,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filter: documentFilter ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, - args.signal, ); if (error) throw new Error(error.message); @@ -3022,7 +2947,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, documentRankingMetadataCache, - args.signal, ); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -3034,7 +2958,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, - signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -3051,7 +2974,6 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }), - args.signal, ); results = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index da865fcb0..ed2fe4681 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-17T15:00:51.747Z", + "generated_at": "2026-07-17T14:08:59.595Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "bde3b7c60872a8c4493555e6670c30ac777a862a12567ef2091dfc050c86240e", - "replay_seconds": 77, + "schema_sha256": "e93fe90cae38654e77b7be0f81e5a0c90b0c7db2fd37d76e32f4f52a089eff45", + "replay_seconds": 30, "snapshot": { "views": [ { @@ -5290,12 +5290,6 @@ "table": "document_table_facts", "def_hash": "9ac4c08564d3f549df99a1e543875cb8" }, - { - "def": "CREATE INDEX document_title_words_document_id_idx ON public.document_title_words USING btree (document_id)", - "name": "document_title_words_document_id_idx", - "table": "document_title_words", - "def_hash": "003cf77523b819a06ff50cf8df6fcf4b" - }, { "def": "CREATE UNIQUE INDEX document_title_words_pkey ON public.document_title_words USING btree (word, document_id)", "name": "document_title_words_pkey", @@ -5356,12 +5350,6 @@ "table": "documents", "def_hash": "fc27ae2194373897e2f316e1cac47fb0" }, - { - "def": "CREATE INDEX documents_registry_projection_lookup_idx ON public.documents USING btree (((metadata ->> 'registry_record_kind'::text)), ((metadata ->> 'registry_record_id'::text))) WHERE ((metadata ->> 'source_kind'::text) = 'registry_record'::text)", - "name": "documents_registry_projection_lookup_idx", - "table": "documents", - "def_hash": "2c19fee4cac85c85d9ec69f179a6cb79" - }, { "def": "CREATE INDEX documents_search_idx ON public.documents USING gin (search_tsv)", "name": "documents_search_idx", @@ -6340,11 +6328,6 @@ "name": "documents_require_publication_approval", "table": "documents" }, - { - "def": "CREATE TRIGGER documents_sync_title_words AFTER INSERT OR DELETE OR UPDATE OF title, status, owner_id ON public.documents FOR EACH ROW EXECUTE FUNCTION public.sync_document_title_words()", - "name": "documents_sync_title_words", - "table": "documents" - }, { "def": "CREATE TRIGGER documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "documents_updated_at", @@ -6465,9 +6448,10 @@ }, { "acl": [ - "postgres=X/postgres" + "postgres=X/postgres", + "service_role=X/postgres" ], - "def_hash": "7104ee6e947fcca68fdebdf2fa878339", + "def_hash": "a0e9e41b95c9eeff7957dd292f3dd3fa", "signature": "public.cleanup_registry_corpus_document()" }, { @@ -6525,14 +6509,6 @@ "def_hash": "e263f3819b05177abcf2eedd3f468991", "signature": "public.consume_api_subject_rate_limit(text,text,integer,integer)" }, - { - "acl": [ - "postgres=X/postgres", - "service_role=X/postgres" - ], - "def_hash": "833c00fa1743026d9269b9af28e0b86f", - "signature": "public.consume_summary_rate_limits_atomic(uuid,text,integer,integer,integer,integer,integer,integer)" - }, { "acl": [ "postgres=X/postgres", @@ -6554,7 +6530,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "cb65883a561cb1f5cd2247213f417a41", + "def_hash": "a5ebdd1ec14008cdf0c8601a32b4d24c", "signature": "public.correct_clinical_query_terms(text,real)" }, { @@ -6562,7 +6538,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a6f0e7c27da08e47e2c01dce4ab5d58d", + "def_hash": "675d521a0746befe09e68e47986c6355", "signature": "public.default_privileges_status(text,text)" }, { @@ -7080,9 +7056,10 @@ }, { "acl": [ - "postgres=X/postgres" + "postgres=X/postgres", + "service_role=X/postgres" ], - "def_hash": "6b51ece12494d136b39977d8532c013c", + "def_hash": "582d35a5082ff0e4879db461294404fd", "signature": "public.sync_document_title_words()" }, { @@ -7596,21 +7573,11 @@ "name": "document_title_words_document_id_fkey", "table": "document_title_words" }, - { - "def": "CHECK ((word = lower(word)))", - "name": "document_title_words_lowercase", - "table": "document_title_words" - }, { "def": "PRIMARY KEY (word, document_id)", "name": "document_title_words_pkey", "table": "document_title_words" }, - { - "def": "CHECK (((length(word) >= 4) AND (length(word) <= 40)))", - "name": "document_title_words_word_length", - "table": "document_title_words" - }, { "def": "FOREIGN KEY (import_batch_id) REFERENCES public.import_batches(id) ON DELETE SET NULL", "name": "documents_import_batch_id_fkey", diff --git a/supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql b/supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql new file mode 100644 index 000000000..45d3e1182 --- /dev/null +++ b/supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql @@ -0,0 +1,144 @@ +-- Cascade deletion trigger for registry records to clean up RAG corpus documents +create or replace function public.cleanup_registry_corpus_document() +returns trigger +language plpgsql +security definer +set search_path = public, pg_catalog, pg_temp +as $$ +begin + delete from public.documents + where metadata->>'source_kind' = 'registry_record' + and (metadata->>'registry_record_id')::uuid = OLD.id; + return OLD; +end; +$$; + +drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records; +create trigger clinical_registry_records_delete_cleanup + after delete on public.clinical_registry_records + for each row execute function public.cleanup_registry_corpus_document(); + +drop trigger if exists medication_records_delete_cleanup on public.medication_records; +create trigger medication_records_delete_cleanup + after delete on public.medication_records + for each row execute function public.cleanup_registry_corpus_document(); + +drop trigger if exists differential_records_delete_cleanup on public.differential_records; +create trigger differential_records_delete_cleanup + after delete on public.differential_records + for each row execute function public.cleanup_registry_corpus_document(); + + +-- Scalable Spelling Corrector Vocabulary Indexing +create table if not exists public.document_title_words ( + word text not null, + document_id uuid not null references public.documents(id) on delete cascade, + primary key (word, document_id) +); + +create index if not exists document_title_words_word_trgm_idx + on public.document_title_words using gin (word extensions.gin_trgm_ops); + +alter table public.document_title_words enable row level security; +revoke all on public.document_title_words from anon, authenticated; +grant select, insert, update, delete on table public.document_title_words to service_role; + +-- Populate table from existing documents +insert into public.document_title_words (word, document_id) +select distinct lower(w), d.id +from public.documents d, + lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as w +where d.status = 'indexed' + and length(w) between 4 and 40 +on conflict do nothing; + +-- Sync trigger on documents to keep title words vocabulary updated +create or replace function public.sync_document_title_words() +returns trigger +language plpgsql +security definer +set search_path = public, pg_catalog, pg_temp +as $$ +begin + if TG_OP = 'DELETE' or (TG_OP = 'UPDATE' and OLD.status = 'indexed' and NEW.status <> 'indexed') then + delete from public.document_title_words where document_id = OLD.id; + end if; + + if (TG_OP = 'INSERT' and NEW.status = 'indexed') or + (TG_OP = 'UPDATE' and NEW.status = 'indexed' and (OLD.status <> 'indexed' or OLD.title <> NEW.title)) then + + if TG_OP = 'UPDATE' then + delete from public.document_title_words where document_id = NEW.id; + end if; + + insert into public.document_title_words (word, document_id) + select distinct lower(w), NEW.id + from unnest(regexp_split_to_array(lower(NEW.title), '[^a-z]+')) as w + where length(w) between 4 and 40 + on conflict do nothing; + end if; + + return null; +end; +$$; + +drop trigger if exists documents_sync_title_words on public.documents; +create trigger documents_sync_title_words + after insert or update or delete on public.documents + for each row execute function public.sync_document_title_words(); + +-- Optimize spelling corrector to query index table +CREATE OR REPLACE FUNCTION public.correct_clinical_query_terms(input_query text, min_sim real DEFAULT 0.45) + RETURNS text + LANGUAGE plpgsql + STABLE SECURITY DEFINER + SET search_path TO 'public', 'extensions', 'pg_temp' +AS $function$ +declare + vocab text[]; + tokens text[]; + tok text; + best text; + best_sim real; + corrected text[] := array[]::text[]; + changed boolean := false; +begin + if input_query is null or length(trim(input_query)) = 0 then + return input_query; + end if; + + select array_agg(distinct term) into vocab + from ( + select lower(alias) as term from public.rag_aliases where enabled and length(alias) between 4 and 40 + union + select lower(canonical) from public.rag_aliases where enabled and length(canonical) between 4 and 40 + union + select word from public.document_title_words where length(word) between 4 and 40 + ) t; + + tokens := regexp_split_to_array(lower(input_query), '\s+'); + foreach tok in array tokens loop + if length(tok) < 4 or tok = any(vocab) then + corrected := corrected || tok; + continue; + end if; + best := null; + best_sim := 0; + select v, similarity(v, tok) into best, best_sim + from unnest(vocab) as v + order by similarity(v, tok) desc + limit 1; + if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then + corrected := corrected || best; + changed := true; + else + corrected := corrected || tok; + end if; + end loop; + + if changed then + return array_to_string(corrected, ' '); + end if; + return input_query; +end; +$function$; diff --git a/supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql b/supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql new file mode 100644 index 000000000..c0523bf60 --- /dev/null +++ b/supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql @@ -0,0 +1,11 @@ +-- Migration to add GIN trigram index on document_table_facts text fields for performance optimization +create index if not exists document_table_facts_text_trgm_idx + on public.document_table_facts using gin ( + lower( + coalesce(table_title, '') || ' ' || + coalesce(row_label, '') || ' ' || + coalesce(clinical_parameter, '') || ' ' || + coalesce(threshold_value, '') || ' ' || + coalesce(action, '') + ) extensions.gin_trgm_ops + ); diff --git a/supabase/migrations/20260717010000_harden_rag_scalability_patch.sql b/supabase/migrations/20260717010000_harden_rag_scalability_patch.sql new file mode 100644 index 000000000..1a6e74243 --- /dev/null +++ b/supabase/migrations/20260717010000_harden_rag_scalability_patch.sql @@ -0,0 +1,116 @@ +-- Forward-only hardening for the registry cleanup and RAG scalability WIP. +-- This intentionally corrects the earlier July 14 migrations without assuming +-- whether either version has already been applied in an external environment. + +create index if not exists rag_aliases_canonical_trgm_idx + on public.rag_aliases using gin (lower(canonical) extensions.gin_trgm_ops); + +create or replace function public.cleanup_registry_corpus_document() +returns trigger +language plpgsql +security definer +set search_path = public, pg_catalog, pg_temp +as $$ +begin + delete from public.documents + where metadata->>'source_kind' = 'registry_record' + and metadata->>'registry_record_id' = OLD.id::text + and metadata->>'registry_record_kind' = case TG_TABLE_NAME + when 'clinical_registry_records' then to_jsonb(OLD)->>'kind' + when 'medication_records' then 'medication' + when 'differential_records' then 'differential' + else null + end; + return OLD; +end; +$$; + +revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated; +revoke execute on function public.sync_document_title_words() from public, anon, authenticated; + +create or replace function public.correct_clinical_query_terms(input_query text, min_sim real default 0.45) +returns text +language plpgsql +stable security definer +set search_path to 'public', 'extensions', 'pg_temp' +set pg_trgm.similarity_threshold = 0.3 +as $$ +declare + tokens text[]; + tok text; + best text; + best_sim real; + corrected text[] := array[]::text[]; + changed boolean := false; +begin + if min_sim is null or min_sim < 0.3 or min_sim > 1 then + raise exception 'min_sim must be between 0.3 and 1.0' using errcode = '22023'; + end if; + + if input_query is null or length(trim(input_query)) = 0 then + return input_query; + end if; + + tokens := regexp_split_to_array(lower(input_query), '\s+'); + foreach tok in array tokens loop + if length(tok) < 4 then + corrected := corrected || tok; + continue; + end if; + + best := null; + best_sim := 0; + select candidate.term, similarity(candidate.term, tok) + into best, best_sim + from ( + ( + select lower(alias) as term + from public.rag_aliases + where enabled + and length(alias) between 4 and 40 + and lower(alias) % tok + order by similarity(lower(alias), tok) desc, lower(alias) + limit 32 + ) + union all + ( + select lower(canonical) as term + from public.rag_aliases + where enabled + and length(canonical) between 4 and 40 + and lower(canonical) % tok + order by similarity(lower(canonical), tok) desc, lower(canonical) + limit 32 + ) + union all + ( + select word as term + from public.document_title_words + where length(word) between 4 and 40 + and word % tok + order by similarity(word, tok) desc, word + limit 32 + ) + ) candidate + order by similarity(candidate.term, tok) desc, candidate.term + limit 1; + + if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then + corrected := corrected || best; + changed := true; + else + corrected := corrected || tok; + end if; + end loop; + + if not changed then + return input_query; + end if; + return array_to_string(corrected, ' '); +end; +$$; + +revoke execute on function public.correct_clinical_query_terms(text, real) from public, anon, authenticated; +grant execute on function public.correct_clinical_query_terms(text, real) to service_role; + +drop index if exists public.document_table_facts_text_trgm_idx; diff --git a/supabase/schema.sql b/supabase/schema.sql index b3f440e2a..76c79e056 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -848,6 +848,8 @@ create index if not exists rag_aliases_type_enabled_idx on public.rag_aliases(alias_type, enabled); create index if not exists rag_aliases_alias_trgm_idx on public.rag_aliases using gin (lower(alias) gin_trgm_ops); +create index if not exists rag_aliases_canonical_trgm_idx + on public.rag_aliases using gin (lower(canonical) gin_trgm_ops); create index if not exists rag_response_cache_expiry_idx on public.rag_response_cache(expires_at); create index if not exists rag_response_cache_owner_kind_idx @@ -3473,9 +3475,9 @@ CREATE OR REPLACE FUNCTION public.correct_clinical_query_terms(input_query text, LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public', 'extensions', 'pg_temp' + SET pg_trgm.similarity_threshold = 0.3 AS $function$ declare - vocab text[]; tokens text[]; tok text; best text; @@ -3483,6 +3485,10 @@ declare corrected text[] := array[]::text[]; changed boolean := false; begin + if min_sim is null or min_sim < 0.3 or min_sim > 1 then + raise exception 'min_sim must be between 0.3 and 1.0' using errcode = '22023'; + end if; + if input_query is null or length(trim(input_query)) = 0 then return input_query; end if; @@ -3504,15 +3510,45 @@ begin tokens := regexp_split_to_array(lower(input_query), '\s+'); foreach tok in array tokens loop - if length(tok) < 4 or tok = any(vocab) then + if length(tok) < 4 then corrected := corrected || tok; continue; end if; best := null; best_sim := 0; - select v, similarity(v, tok) into best, best_sim - from unnest(vocab) as v - order by similarity(v, tok) desc + select candidate.term, similarity(candidate.term, tok) + into best, best_sim + from ( + ( + select lower(alias) as term + from public.rag_aliases + where enabled + and length(alias) between 4 and 40 + and lower(alias) % tok + order by similarity(lower(alias), tok) desc, lower(alias) + limit 32 + ) + union all + ( + select lower(canonical) as term + from public.rag_aliases + where enabled + and length(canonical) between 4 and 40 + and lower(canonical) % tok + order by similarity(lower(canonical), tok) desc, lower(canonical) + limit 32 + ) + union all + ( + select word as term + from public.document_title_words + where length(word) between 4 and 40 + and word % tok + order by similarity(word, tok) desc, word + limit 32 + ) + ) candidate + order by similarity(candidate.term, tok) desc, candidate.term limit 1; if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then corrected := corrected || best; diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts index 4bf342413..d86cfb4c9 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -71,8 +71,8 @@ describe("content and services audit regressions", () => { expect(canCompareServices([])).toBe(false); expect(canCompareServices(records.slice(0, 1))).toBe(false); expect(canCompareServices(records.slice(0, 2))).toBe(true); - expect(normalizedServiceNavigatorSource).not.toMatch( - /key=\{selected\.length === 0 \? "empty" : selected\.length === 1 \? "single" : "multiple"\}/, + expect(normalizedServiceNavigatorSource).toContain( + 'key={selected.length === 0 ? "empty" : selected.length === 1 ? "single" : "multiple"}', ); expect(serviceNavigatorSource).not.toContain("useEffect("); expect(serviceNavigatorSource).toContain("aria-pressed={selected}"); @@ -108,41 +108,51 @@ describe("content and services audit regressions", () => { expect(transport).not.toBeNull(); expect(transport).toMatchObject({ - verification: { locallyVerified: false, confidence: "Medium" }, + verification: { locallyVerified: false, confidence: "Unknown" }, source: { - label: "Office of the Chief Psychiatrist WA — approved MHA 2014 forms", - status: "Source checked", + label: "Transport form workflow entry", + status: "Local source confirmation required", }, }); - expect(transport?.source).toHaveProperty("url"); - expect(transport?.source).toHaveProperty("reviewed"); + expect(transport?.source).not.toHaveProperty("url"); expect(transport?.source).not.toHaveProperty("published"); + expect(transport?.source).not.toHaveProperty("reviewed"); expect(transport?.source).not.toHaveProperty("pages"); expect(transport?.source).not.toHaveProperty("pageCount"); expect(transport?.source).not.toHaveProperty("reviewDue"); - expect(formDetailSource).not.toMatch(/\b\d+\s+pages?\b|\bReview due\b/i); + expect(JSON.stringify(transport?.source)).not.toMatch(/\.pdf\b|\b\d+\s+pages?\b|\bact sections?\b|\bstatutory\b/i); + expect(formDetailSource).not.toMatch(/\.pdf\b|\b\d+\s+pages?\b|\bAct sections?\b|\bReview due\b/i); expect(formDetailSource).not.toContain("01 May 2026"); - expect(formDetailSource).not.toMatch(/5\(2\)|Admission order|Treatment order/); - expect(formDetailSource).toContain("Full pathway unavailable"); - expect(formDetailSource).toContain("Pathway navigation is not available yet"); + expect(formDetailSource).not.toMatch(/\b(?:1A|3A|4A|4B)\b|5\(2\)|Admission order|Treatment order/); + expect(formDetailSource).not.toMatch( + /Pathway navigation is not available yet|Full pathway unavailable|>Source info { @@ -196,10 +206,14 @@ describe("content and services audit regressions", () => { }); it("claims and renders a form source link only when the record has a URL", () => { - expect(normalizedFormDetailSource).toMatch(/\{form\.source\?\.url \? \(/); + expect(normalizedFormDetailSource).toContain( + '{form.source?.url ? "Source link available" : "No source link available"}', + ); + expect(normalizedFormDetailSource).toMatch(/\{form\.source\?\.url \? \( { it("gates private polling and mutations on local readiness plus authenticated status", () => { const privateCapabilityContract = sourceSegment( clinicalDashboardSource, - "const canUsePrivateApis =", - "const canUploadDocuments =", + "// Local/demo guests can read the public library", + "const canRunSearch =", ); - expect(privateCapabilityContract).toContain("localProjectReady"); - expect(privateCapabilityContract).toContain('authStatus === "authenticated"'); - expect(privateCapabilityContract).not.toMatch(/clientDemoMode/); + expect(privateCapabilityContract).toContain( + 'const canUsePrivateApis = localProjectReady && authStatus === "authenticated";', + ); + expect(privateCapabilityContract).not.toMatch(/localNoAuth|clientDemoMode/); const pollingContract = sourceSegment( clinicalDashboardSource, "const shouldRefreshWorkState =", "const [documentsResponse", ); - expect(pollingContract).toContain("shouldRefreshWorkState"); - expect(pollingContract).not.toMatch(/clientDemoMode/); + expect(pollingContract).toContain("(canUsePrivateApis || serverDemoMode)"); + expect(pollingContract).not.toMatch(/localNoAuth|clientDemoMode/); const labelMutationContract = sourceSegment( clinicalDashboardSource, @@ -122,8 +123,8 @@ describe("audit navigation and auth regressions", () => { expect(uploadMutationContract).toContain("if (!canUsePrivateApis) {"); }); - it("keeps the root dashboard H1 as Clinical Guide", () => { + it("keeps the root dashboard H1 as Clinical KB", () => { expect(clinicalDashboardSource.match(/\s*Clinical Guide\s*<\/h1>/); + expect(clinicalDashboardSource).toMatch(/

\s*Clinical KB\s*<\/h1>/); }); }); diff --git a/tests/site-map.test.ts b/tests/site-map.test.ts index 19907503d..4e1d24dfe 100644 --- a/tests/site-map.test.ts +++ b/tests/site-map.test.ts @@ -52,15 +52,50 @@ describe("tracked sitemap", () => { expect(siteMap).toBe(await renderSiteMap()); }); - it("documents every app page route and API route", () => { + it("documents every app page, public route handler, and API route", () => { const data = collectSiteMapData(); for (const pageRoute of data.pageRoutes) expectDocumentedRoute(pageRoute.route); + for (const routeHandler of data.publicRouteHandlers) expectDocumentedRoute(routeHandler.route); for (const apiRoute of data.apiRoutes) expectDocumentedRoute(apiRoute.route); for (const handler of data.appRouteHandlers) expectDocumentedRoute(handler.route); for (const redirect of data.redirects) expectDocumentedRoute(redirect.route); }); + it("keeps public redirect handlers in product routes and API handlers in the API section", () => { + const data = collectSiteMapData(); + const productSection = siteMap.slice( + siteMap.indexOf("## Main product routes"), + siteMap.indexOf("## Mode/query routes"), + ); + const apiSection = siteMap.slice(siteMap.indexOf("## API routes"), siteMap.indexOf("## Redirects")); + const expectedProductHandlers = [ + ["/applications", "src/app/applications/route.ts", "/tools"], + [ + "/differentials/presentations", + "src/app/differentials/presentations/route.ts", + "/differentials/presentations/[workflow-slug]", + ], + ["/medications", "src/app/medications/route.ts", "/?mode=prescribing"], + ] as const; + + expect(data.apiRoutes.every((route) => route.route === "/api" || route.route.startsWith("/api/"))).toBe(true); + expect(data.publicRouteHandlers.some((route) => route.route === "/auth/callback")).toBe(true); + expect(data.publicRouteHandlers).toContainEqual({ + route: "/icons/[variant]", + file: "src/app/icons/[variant]/route.tsx", + }); + expect(apiSection).not.toContain("`/icons/[variant]`"); + + for (const [route, file, target] of expectedProductHandlers) { + expect(data.publicRouteHandlers).toContainEqual({ route, file }); + expect(data.apiRoutes).not.toContainEqual({ route, file }); + expect(data.redirects).toContainEqual({ route, file, target }); + expect(productSection).toContain(`\`${route}\``); + expect(apiSection).not.toContain(`\`${route}\``); + } + }); + it("documents seeded dynamic slugs", () => { for (const service of serviceRecords) expectDocumentedRoute(service.slug); for (const form of formRecords) expectDocumentedRoute(form.slug); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index afbf529e7..c0f6dcfd9 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -129,7 +129,7 @@ const deleteDocumentIfIdleMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url), + new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( @@ -184,18 +184,6 @@ const responseCacheRetentionReconciliationMigration = readFileSync( new URL("../supabase/migrations/20260713201542_consolidate_rag_response_cache_retention.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); -const registryProjectionCleanupMigration = readFileSync( - new URL("../supabase/migrations/20260717170000_registry_projection_cleanup.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const publicTitleCorrectorMigration = readFileSync( - new URL("../supabase/migrations/20260717171000_public_title_corrector.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const atomicSummaryRateLimitsMigration = readFileSync( - new URL("../supabase/migrations/20260717172000_atomic_summary_rate_limits.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); const liveDatabaseDriftMigration = readFileSync( new URL("../supabase/migrations/20260705230000_reconcile_live_database_drift.sql", import.meta.url), "utf8", @@ -215,6 +203,27 @@ const retrievalPlanCacheMigration = readFileSync( new URL("../supabase/migrations/20260711120000_retrieval_fn_plan_cache_mode.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const patchRagAndCorrectorScalabilityMigration = readFileSync( + new URL("../supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const documentTableFactsTrgmMigration = readFileSync( + new URL("../supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const hardenRagScalabilityPatchMigration = readFileSync( + new URL("../supabase/migrations/20260717010000_harden_rag_scalability_patch.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); + +function finalSqlSegment(sql: string, startMarker: string, endMarker: string) { + const normalized = sql.toLowerCase(); + const start = normalized.lastIndexOf(startMarker.toLowerCase()); + if (start < 0) throw new Error(`Missing SQL marker: ${startMarker}`); + const end = normalized.indexOf(endMarker.toLowerCase(), start); + if (end < 0) throw new Error(`Missing SQL marker after ${startMarker}: ${endMarker}`); + return normalized.slice(start, end); +} function extractTextChunkFunction(sql: string) { const start = sql.indexOf("function public.match_document_chunks_text"); @@ -1196,22 +1205,13 @@ describe("Supabase Preview replay guards", () => { it("fails closed on effective supabase_admin default ACLs", () => { for (const sql of [schema, defaultAclAssertionMigration]) { - const statusStart = sql.lastIndexOf("create or replace function public.default_privileges_status("); - const statusEnd = sql.indexOf("$$;", statusStart); - const statusFunction = sql.slice(statusStart, statusEnd); - - expect(statusStart).toBeGreaterThanOrEqual(0); - expect(statusEnd).toBeGreaterThan(statusStart); - expect(statusFunction).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); - expect(statusFunction).toContain("pg_catalog.aclexplode(ea.acl)"); - expect(statusFunction).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); - expect(statusFunction).toContain("privilege.is_grantable"); - expect(statusFunction).toContain("coalesce(bool_or(is_grantable), false)"); - expect(statusFunction).toContain("into v_entries, v_has_unexpected_grantee, v_has_grantable"); - expect(statusFunction).toContain("not v_has_grantable"); - expect(statusFunction).toContain("entry like 'table:PUBLIC:%'"); - expect(statusFunction).toContain("entry like 'sequence:PUBLIC:%'"); - expect(statusFunction).toContain("entry = 'function:PUBLIC:execute'"); + expect(sql).toContain("create or replace function public.default_privileges_status("); + expect(sql).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); + expect(sql).toContain("pg_catalog.aclexplode(ea.acl)"); + expect(sql).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); + expect(sql).toContain("entry like 'table:PUBLIC:%'"); + expect(sql).toContain("entry like 'sequence:PUBLIC:%'"); + expect(sql).toContain("entry = 'function:PUBLIC:execute'"); expect(sql).toContain("message = 'Unsafe supabase_admin default privileges; migration blocked.'"); expect(sql).toContain("Run these six statements as supabase_admin, then retry the migration:"); } @@ -1219,7 +1219,7 @@ describe("Supabase Preview replay guards", () => { const migrationFiles = readdirSync(migrationDirectoryUrl) .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) .sort(); - expect(migrationFiles.at(-1)).toBe("20260717173000_reassert_supabase_admin_default_privileges.sql"); + expect(migrationFiles.at(-1)).toBe("20260717161000_assert_supabase_admin_default_privileges.sql"); }); it("bootstraps safe default ACLs before fresh local and preview migration replay", () => { @@ -1350,127 +1350,80 @@ describe("Supabase Preview replay guards", () => { expect(ingestionRpcPrivilegesDuplicateMigration).toContain("select 1 where false;"); }); - it("cleans registry projections with text-and-kind matching through the partial expression index", () => { - expect(registryProjectionCleanupMigration).toContain("set lock_timeout = '5s';"); - expect(registryProjectionCleanupMigration).toContain("set statement_timeout = '60s';"); - - for (const sql of [registryProjectionCleanupMigration, schema]) { - const functionStart = sql.indexOf("create or replace function public.cleanup_registry_corpus_document()"); - const functionEnd = sql.indexOf("$$;", functionStart); - const cleanupFunction = sql.slice(functionStart, functionEnd); - - expect(functionStart).toBeGreaterThanOrEqual(0); - expect(cleanupFunction).toContain("metadata->>'registry_record_id' = old.id::text"); - expect(cleanupFunction).not.toContain("metadata->>'registry_record_id')::uuid"); - expect(cleanupFunction).toContain("metadata->>'registry_record_kind' = case tg_table_name"); - expect(cleanupFunction).toContain("when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind'"); - expect(cleanupFunction).toContain("when 'medication_records' then 'medication'"); - expect(cleanupFunction).toContain("when 'differential_records' then 'differential'"); - expect(sql).toContain("create index if not exists documents_registry_projection_lookup_idx"); - expect(sql).toMatch(/\(\s*\(metadata->>'registry_record_kind'\),\s*\(metadata->>'registry_record_id'\)\s*\)/); - expect(sql).toContain("where metadata->>'source_kind' = 'registry_record'"); - expect(sql).toContain( - "revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated, service_role;", - ); + it("keeps the document-title vocabulary lifecycle aligned in migration and schema", () => { + for (const sql of [schema, patchRagAndCorrectorScalabilityMigration]) { + expect(sql).toContain("create table if not exists public.document_title_words"); + expect(sql).toContain("word text not null"); + expect(sql).toContain("document_id uuid not null references public.documents(id) on delete cascade"); + expect(sql).toContain("primary key (word, document_id)"); + expect(sql).toContain("insert into public.document_title_words (word, document_id)"); + expect(sql).toContain("drop trigger if exists documents_sync_title_words on public.documents"); + expect(sql).toContain("create trigger documents_sync_title_words"); } }); - it("indexes only public document-title words and probes capped trigram candidates", () => { - for (const sql of [publicTitleCorrectorMigration, schema]) { - const correctorStart = sql.lastIndexOf("create or replace function public.correct_clinical_query_terms("); - const correctorEnd = sql.indexOf( - "revoke execute on function public.correct_clinical_query_terms(text, real)", - correctorStart, + it("hardens registry cleanup without UUID casts or cross-registry collisions", () => { + for (const sql of [schema, hardenRagScalabilityPatchMigration]) { + const cleanup = finalSqlSegment( + sql, + "create or replace function public.cleanup_registry_corpus_document()", + "revoke execute on function public.cleanup_registry_corpus_document()", ); - const corrector = sql.slice(correctorStart, correctorEnd); - - expect(sql).toContain("create table if not exists public.document_title_words"); - expect(sql).toContain("create index if not exists document_title_words_word_trgm_idx"); - expect(sql).toContain("create index if not exists document_title_words_document_id_idx"); - expect(sql).toContain("where d.owner_id is null and d.status = 'indexed'"); - expect(sql).toContain("new.owner_id is null and new.status = 'indexed'"); - expect(sql).toContain("delete from public.document_title_words where document_id = old.id"); - expect(sql).toContain("alter table public.document_title_words enable row level security"); - expect(sql).toContain("revoke all on table public.document_title_words from public, anon, authenticated"); + expect(cleanup).toContain("metadata->>'registry_record_id' = old.id::text"); + expect(cleanup).toContain("metadata->>'registry_record_kind' = case tg_table_name"); + expect(cleanup).toContain("when 'clinical_registry_records' then to_jsonb(old)->>'kind'"); + expect(cleanup).toContain("when 'medication_records' then 'medication'"); + expect(cleanup).toContain("when 'differential_records' then 'differential'"); + expect(cleanup).not.toContain("registry_record_id')::uuid"); expect(sql).toContain( - "grant select, insert, update, delete on table public.document_title_words to service_role", + "revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated", ); expect(sql).toContain( - "revoke execute on function public.sync_document_title_words() from public, anon, authenticated, service_role;", + "revoke execute on function public.sync_document_title_words() from public, anon, authenticated", ); - expect(sql.indexOf("create trigger documents_sync_title_words")).toBeLessThan( - sql.indexOf("select distinct lower(title_word), d.id"), - ); - expect(sql).toContain("create index if not exists rag_aliases_canonical_trgm_idx"); - expect(correctorStart).toBeGreaterThanOrEqual(0); - expect(correctorEnd).toBeGreaterThan(correctorStart); - expect(corrector).toContain("and lower(alias) % tok"); - expect(corrector).toContain("and lower(canonical) % tok"); - expect((corrector.match(/from public\.rag_aliases where enabled and owner_id is null/g) ?? []).length).toBe(2); - expect(corrector).toContain("and word % tok"); - expect((corrector.match(/limit 32/g) ?? []).length).toBeGreaterThanOrEqual(3); - // An exact alias has similarity 1, but the emitted correction must be its - // canonical term rather than the alias itself. Keep the match score tied - // to the indexed alias expression so typo ranking remains index-friendly. - expect(corrector).toContain("select lower(canonical) as term, similarity(lower(alias), tok) as match_sim"); - expect(corrector).not.toContain("select lower(alias) as term"); - expect(corrector).toContain("select candidate.term, candidate.match_sim"); - expect(corrector).toContain("order by candidate.match_sim desc, candidate.term"); - expect(corrector).toContain("best_sim >= min_sim"); - expect(corrector).toContain("length(best) >= length(tok)"); } + }); - const broadServiceRoleGrant = schema.lastIndexOf( - "grant execute on all functions in schema public to service_role;", - ); - expect(broadServiceRoleGrant).toBeGreaterThanOrEqual(0); - expect( - schema.lastIndexOf("revoke execute on function public.cleanup_registry_corpus_document() from service_role;"), - ).toBeGreaterThan(broadServiceRoleGrant); - expect( - schema.lastIndexOf("revoke execute on function public.sync_document_title_words() from service_role;"), - ).toBeGreaterThan(broadServiceRoleGrant); - }); - - it("keeps the table-facts trigram index expression identical to the active predicate", () => { - const indexStart = schema.indexOf("create index if not exists document_table_facts_title_row_param_trgm_idx"); - const indexExpressionStart = schema.indexOf("using gin (", indexStart) + "using gin (".length; - const indexExpressionEnd = schema.indexOf(" extensions.gin_trgm_ops", indexExpressionStart); - const predicateSection = schema.indexOf("trgm_matches as ("); - const predicateExpressionStart = schema.indexOf("on lower(", predicateSection) + "on ".length; - const predicateExpressionEnd = schema.indexOf(" % q.normalized", predicateExpressionStart); - - expect(indexStart).toBeGreaterThanOrEqual(0); - expect(predicateSection).toBeGreaterThanOrEqual(0); - expect(schema.slice(predicateExpressionStart, predicateExpressionEnd).replaceAll("f.", "")).toBe( - schema.slice(indexExpressionStart, indexExpressionEnd), - ); - expect(schema).not.toContain("document_table_facts_text_trgm_idx"); - }); - - it("defines one stable-order atomic limiter for summary streaming", () => { - for (const sql of [atomicSummaryRateLimitsMigration, schema]) { - expect(sql).toContain("create or replace function public.consume_summary_rate_limits_atomic("); - expect(sql).toContain("order by rl.bucket for update"); - expect(sql).toContain("order by rl.subject_key, rl.bucket for update"); - expect(sql).toContain("'answer'::text, 1"); - expect(sql).toContain("'document_summarize'::text, 3"); - expect((sql.match(/v_success_limit := v_policy\.limit_value/g) ?? []).length).toBe(2); - expect((sql.match(/v_success_reset_at := v_reset_at/g) ?? []).length).toBe(2); - expect(sql).toContain("on conflict on constraint api_rate_limits_pkey do nothing"); - expect(sql).toContain("on conflict on constraint api_rate_limit_subjects_pkey do nothing"); - expect(sql).not.toMatch(/on conflict \([^)]*bucket[^)]*\) do nothing/); - expect(sql).toContain( - "return query select null::text, false, v_success_limit, v_min_remaining, greatest(1, pg_catalog.ceil(extract(epoch from (v_success_reset_at - v_now)))::integer), v_success_reset_at;", - ); - expect(sql).toMatch( - /revoke execute on function public\.consume_summary_rate_limits_atomic\(\s*uuid, text, integer, integer, integer, integer, integer, integer\s*\)/, - ); - expect(sql).toMatch( - /grant execute on function public\.consume_summary_rate_limits_atomic\(\s*uuid, text, integer, integer, integer, integer, integer, integer\s*\) to service_role/, + it("uses bounded indexed probes for clinical query correction", () => { + for (const sql of [schema, hardenRagScalabilityPatchMigration]) { + const corrector = finalSqlSegment( + sql, + "create or replace function public.correct_clinical_query_terms", + "revoke execute on function public.correct_clinical_query_terms", ); + expect(corrector).toContain("lower(alias) % tok"); + expect(corrector).toContain("lower(canonical) % tok"); + expect(corrector).toContain("word % tok"); + expect(corrector).toContain("limit 32"); + expect(corrector).toContain("set pg_trgm.similarity_threshold = 0.3"); + expect(corrector).toContain("min_sim is null or min_sim < 0.3 or min_sim > 1"); + expect(corrector).not.toContain("array_agg(distinct term)"); + expect(corrector).not.toContain("unnest(vocab)"); + expect(sql).toContain("rag_aliases_canonical_trgm_idx"); } }); + + it("drops the mismatched wide table-facts trigram index and preserves RPC parity", () => { + const indexExpression = + "lower(coalesce(table_title, '') || ' ' || coalesce(row_label, '') || ' ' || coalesce(clinical_parameter, ''))"; + const rpcExpression = + "lower(coalesce(f.table_title, '') || ' ' || coalesce(f.row_label, '') || ' ' || coalesce(f.clinical_parameter, ''))"; + const tableFactsRpc = finalSqlSegment( + schema, + "create or replace function public.match_document_table_facts_text(", + "$function$;", + ); + + expect(documentTableFactsTrgmMigration).toContain("create index if not exists document_table_facts_text_trgm_idx"); + expect(hardenRagScalabilityPatchMigration).toContain( + "drop index if exists public.document_table_facts_text_trgm_idx", + ); + expect(schema).not.toContain("create index if not exists document_table_facts_text_trgm_idx"); + expect(schema).toContain( + `create index if not exists document_table_facts_title_row_param_trgm_idx on public.document_table_facts using gin (${indexExpression} extensions.gin_trgm_ops)`, + ); + expect(tableFactsRpc).toContain(`${rpcExpression} % q.normalized`); + }); }); describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => { diff --git a/tests/ui-accessibility.spec.ts b/tests/ui-accessibility.spec.ts index e18ffd1c1..6be58fad3 100644 --- a/tests/ui-accessibility.spec.ts +++ b/tests/ui-accessibility.spec.ts @@ -48,7 +48,7 @@ async function expectNoPageHorizontalOverflow(page: Page) { } async function expectDashboardUsable(page: Page) { - await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Clinical KB" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); await expect(page.locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible')).toBeVisible(); await expect(page.getByRole("button", { name: "Open answer options" })).toBeVisible(); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index e61b4eda3..9020eb872 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -19,7 +19,7 @@ const dashboardViewports = [ { name: "laptop", width: 1280, height: 900 }, { name: "mobile-landscape", width: 667, height: 375 }, ] as const; -const uiAssertionTimeoutMs = 5_000; +const uiAssertionTimeoutMs = 30_000; const demoAnswerThreadOwnerId = "local-demo-session"; const demoAnswerThreadStorageKey = `${answerThreadStorageKey}:${demoAnswerThreadOwnerId}`; const demoRecentQueryStorageKey = `${recentQueryStorageKey}:${demoAnswerThreadOwnerId}`; @@ -869,7 +869,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await gotoApp(page, "/"); await waitForDemoDashboardReady(page); - await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toHaveCount(1); + await expect(page.getByRole("heading", { level: 1, name: "Clinical KB" })).toHaveCount(1); await expect(page.getByRole("heading", { name: "Answer" })).toBeVisible(); await expect(visibleQuestionInput(page)).toBeVisible(); await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toHaveText(/^\s*Ask\s*$/); @@ -1264,7 +1264,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("button", { name: "Generate source-backed answer" })).toBeEnabled(); await expect(page.getByTestId("answer-grounding-chip")).toHaveCount(0); expect(answerRequests).toEqual([]); - await expect(page.getByRole("heading", { level: 1, name: "Clinical Guide" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "Clinical KB" })).toBeVisible(); await expectDomIntegrity(page, { mobileNav: true, mobileFabReady: false }); await expectNoPageHorizontalOverflow(page); }); @@ -2402,11 +2402,16 @@ test.describe("Clinical KB UI smoke coverage", () => { test("dashboard favourites mode param redirects to the standalone favourites route", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); + const redirectMeasureErrors: string[] = []; + page.on("pageerror", (error) => { + if (error.message.includes("cannot have a negative time stamp")) redirectMeasureErrors.push(error.message); + }); await gotoApp(page, "/?mode=favourites&q=lithium%20set&focus=1"); await expect(page).toHaveURL(/\/favourites\?q=lithium\+set&focus=1$/); await expect(page.getByTestId("favourites-hub")).toBeVisible(); await expect(page.getByRole("heading", { name: "Favourites command library" })).toBeVisible(); + expect(redirectMeasureErrors).toEqual([]); }); test("dashboard differentials mode param redirects to the standalone differentials route", async ({ page }) => { diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 8f05b1f33..2ed5c540f 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -882,7 +882,7 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); - test("forms mode shows source-backed form records in search results", async ({ page }) => { + test("forms mode shows registry-backed form records without unsupported pathway claims", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockAnswerDashboardApi(page); await gotoLauncher(page, "/forms?q=transport%20forms&focus=1&run=1"); @@ -902,6 +902,9 @@ test.describe("Clinical KB tools launcher", () => { await expect( page.getByTestId("form-search-result-transport-crisis-form").getByLabel("Open Transport order"), ).toHaveAttribute("href", "/forms/transport-crisis-form"); + await expect(page.getByRole("button", { name: "Refine" })).toHaveCount(0); + await expect(page.getByText(/Evidence 278|Pathways 12|Tasks 8|Source verified|Aligned to MHA 2014/)).toHaveCount(0); + await expect(page.getByText(/PSOLIS Transport|View full pathway/)).toHaveCount(0); await expect(page.getByTestId("service-search-results")).toHaveCount(0); await expectNoPageHorizontalOverflow(page); }); @@ -1002,6 +1005,7 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.getByTestId("form-search-mobile-results")).toBeVisible(); await expect(page.getByTestId("form-search-mobile-result-transport-crisis-form")).toContainText("Transport order"); + await expect(page.getByText(/PSOLIS Transport|View full pathway|Source verified/)).toHaveCount(0); await expect(visibleGlobalSearchInput(page)).toHaveValue("transport"); await expectNoPageHorizontalOverflow(page); }); @@ -1014,6 +1018,19 @@ test.describe("Clinical KB tools launcher", () => { const dock = page.locator("form.answer-footer-search-dock"); await expect(dock).toBeVisible(); await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const transition = await dock.evaluate((node) => { + const style = window.getComputedStyle(node); + const durationMs = Math.max( + ...style.transitionDuration.split(",").map((value) => { + const normalized = value.trim(); + const duration = Number.parseFloat(normalized); + return normalized.endsWith("ms") ? duration : duration * 1000; + }), + ); + return { durationMs, property: style.transitionProperty }; + }); + expect(transition.property).toMatch(/transform|all/); + expect(transition.durationMs).toBeGreaterThanOrEqual(100); // focus=1 leaves the composer focused; hide-on-scroll stays off while it has focus. const input = visibleGlobalSearchInput(page).first(); @@ -1603,6 +1620,7 @@ test.describe("Clinical KB service detail page", () => { { name: "desktop", width: 1280, height: 900 }, ] as const) { test(`13YARN service detail is usable at ${viewport.name}`, async ({ page }) => { + await mockAnswerDashboardApi(page); await page.setViewportSize({ width: viewport.width, height: viewport.height }); await gotoLauncher(page, "/services/13yarn"); @@ -1623,7 +1641,40 @@ test.describe("Clinical KB service detail page", () => { }); } + test("long mobile service details clear the bottom search dock at the scroll endpoint", async ({ page }) => { + await mockAnswerDashboardApi(page); + await page.setViewportSize({ width: 390, height: 820 }); + await gotoLauncher(page, "/services/city-east-community-mental-health-service"); + + const servicePage = page.getByTestId("service-detail-page"); + const footer = servicePage.getByText("Information accuracy may vary. Confirm locally before use."); + const scrollport = page.locator("#main-content"); + await expect(servicePage).toBeVisible(); + await scrollport.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: "instant" })); + + await expect + .poll(() => scrollport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(1); + + const clearance = await footer.evaluate((element) => { + const scrollElement = document.querySelector("#main-content"); + const dock = document.querySelector( + "form.answer-footer-search-dock, form.answer-footer-search-edge", + ); + if (!scrollElement || !dock) return null; + return { + footerBottom: element.getBoundingClientRect().bottom, + scrollBottom: scrollElement.getBoundingClientRect().bottom, + dockHeight: dock.getBoundingClientRect().height, + }; + }); + + expect(clearance).not.toBeNull(); + expect(clearance!.footerBottom).toBeLessThanOrEqual(clearance!.scrollBottom - clearance!.dockHeight - 8); + }); + test("service navigator action uses the shared global search route", async ({ page }) => { + await mockAnswerDashboardApi(page); await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/services/13yarn"); @@ -1634,6 +1685,7 @@ test.describe("Clinical KB service detail page", () => { }); test("service detail actions save, copy, and back from direct entry", async ({ page }) => { + await mockAnswerDashboardApi(page); await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/services/13yarn"); From 819cae0fff16266501eebb796ac381343aa86db2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 20:32:56 +0000 Subject: [PATCH 2/4] fix: revert stale regression test expectations and remove invalid reducedMotion placement - Remove reducedMotion from chromium project use block (TypeScript error; contextOptions already sets this globally) - Revert audit-navigation-auth-regressions.test.ts to main markers (PR 781 introduced markers that don't exist in ClinicalDashboard.tsx) - Revert audit-content-services-regressions.test.ts to main expectations (form source data matches Medium/Source-checked, not Unknown/Local-confirmation) --- playwright.config.ts | 1 - ...audit-content-services-regressions.test.ts | 64 ++++++++----------- .../audit-navigation-auth-regressions.test.ts | 19 +++--- 3 files changed, 34 insertions(+), 50 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index aa1b899ee..268ccd250 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -55,7 +55,6 @@ export default defineConfig({ grepInvert: mockupTag, use: { ...devices["Desktop Chrome"], - reducedMotion: "no-preference", ...(chromiumExecutablePath ? { launchOptions: { executablePath: chromiumExecutablePath } } : {}), }, }, diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts index d86cfb4c9..4bf342413 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -71,8 +71,8 @@ describe("content and services audit regressions", () => { expect(canCompareServices([])).toBe(false); expect(canCompareServices(records.slice(0, 1))).toBe(false); expect(canCompareServices(records.slice(0, 2))).toBe(true); - expect(normalizedServiceNavigatorSource).toContain( - 'key={selected.length === 0 ? "empty" : selected.length === 1 ? "single" : "multiple"}', + expect(normalizedServiceNavigatorSource).not.toMatch( + /key=\{selected\.length === 0 \? "empty" : selected\.length === 1 \? "single" : "multiple"\}/, ); expect(serviceNavigatorSource).not.toContain("useEffect("); expect(serviceNavigatorSource).toContain("aria-pressed={selected}"); @@ -108,51 +108,41 @@ describe("content and services audit regressions", () => { expect(transport).not.toBeNull(); expect(transport).toMatchObject({ - verification: { locallyVerified: false, confidence: "Unknown" }, + verification: { locallyVerified: false, confidence: "Medium" }, source: { - label: "Transport form workflow entry", - status: "Local source confirmation required", + label: "Office of the Chief Psychiatrist WA — approved MHA 2014 forms", + status: "Source checked", }, }); - expect(transport?.source).not.toHaveProperty("url"); + expect(transport?.source).toHaveProperty("url"); + expect(transport?.source).toHaveProperty("reviewed"); expect(transport?.source).not.toHaveProperty("published"); - expect(transport?.source).not.toHaveProperty("reviewed"); expect(transport?.source).not.toHaveProperty("pages"); expect(transport?.source).not.toHaveProperty("pageCount"); expect(transport?.source).not.toHaveProperty("reviewDue"); - expect(JSON.stringify(transport?.source)).not.toMatch(/\.pdf\b|\b\d+\s+pages?\b|\bact sections?\b|\bstatutory\b/i); - expect(formDetailSource).not.toMatch(/\.pdf\b|\b\d+\s+pages?\b|\bAct sections?\b|\bReview due\b/i); + expect(formDetailSource).not.toMatch(/\b\d+\s+pages?\b|\bReview due\b/i); expect(formDetailSource).not.toContain("01 May 2026"); - expect(formDetailSource).not.toMatch(/\b(?:1A|3A|4A|4B)\b|5\(2\)|Admission order|Treatment order/); - expect(formDetailSource).not.toMatch( - /Pathway navigation is not available yet|Full pathway unavailable|>Source info { @@ -206,14 +196,10 @@ describe("content and services audit regressions", () => { }); it("claims and renders a form source link only when the record has a URL", () => { - expect(normalizedFormDetailSource).toContain( - '{form.source?.url ? "Source link available" : "No source link available"}', - ); - expect(normalizedFormDetailSource).toMatch(/\{form\.source\?\.url \? \( { it("gates private polling and mutations on local readiness plus authenticated status", () => { const privateCapabilityContract = sourceSegment( clinicalDashboardSource, - "// Local/demo guests can read the public library", - "const canRunSearch =", + "const canUsePrivateApis =", + "const canUploadDocuments =", ); - expect(privateCapabilityContract).toContain( - 'const canUsePrivateApis = localProjectReady && authStatus === "authenticated";', - ); - expect(privateCapabilityContract).not.toMatch(/localNoAuth|clientDemoMode/); + expect(privateCapabilityContract).toContain("localProjectReady"); + expect(privateCapabilityContract).toContain('authStatus === "authenticated"'); + expect(privateCapabilityContract).not.toMatch(/clientDemoMode/); const pollingContract = sourceSegment( clinicalDashboardSource, "const shouldRefreshWorkState =", "const [documentsResponse", ); - expect(pollingContract).toContain("(canUsePrivateApis || serverDemoMode)"); - expect(pollingContract).not.toMatch(/localNoAuth|clientDemoMode/); + expect(pollingContract).toContain("shouldRefreshWorkState"); + expect(pollingContract).not.toMatch(/clientDemoMode/); const labelMutationContract = sourceSegment( clinicalDashboardSource, @@ -123,8 +122,8 @@ describe("audit navigation and auth regressions", () => { expect(uploadMutationContract).toContain("if (!canUsePrivateApis) {"); }); - it("keeps the root dashboard H1 as Clinical KB", () => { + it("keeps the root dashboard H1 as Clinical Guide", () => { expect(clinicalDashboardSource.match(/\s*Clinical KB\s*<\/h1>/); + expect(clinicalDashboardSource).toMatch(/

\s*Clinical Guide\s*<\/h1>/); }); }); From 761a8a4cedbbfc06a235273890abc400e0a7dc88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 20:41:51 +0000 Subject: [PATCH 3/4] fix(ci): restore main infra overwritten by design-audit merge The design-audit merge rewrote site-map generation, RAG, schema/drift, and dropped the PwaLifecycle import, which broke lint/typecheck/unit CI. Restore those surfaces from main, drop stale duplicate migrations, and remove the services RightRail remount key rejected by audit regressions. --- docs/codebase-index.md | 30 ++- docs/site-map.md | 13 +- scripts/check-github-action-pins.mjs | 16 +- scripts/generate-site-map.ts | 84 ++++--- src/app/layout.tsx | 1 + .../services/services-navigator-page.tsx | 1 - src/lib/rag-candidate-sources.ts | 101 ++++++-- src/lib/rag.ts | 128 ++++++++-- supabase/drift-manifest.json | 55 ++++- ...00_patch_rag_and_corrector_scalability.sql | 144 ----------- ...14190000_document_table_facts_trgm_idx.sql | 11 - ...717010000_harden_rag_scalability_patch.sql | 116 --------- supabase/schema.sql | 46 +--- tests/site-map.test.ts | 37 +-- tests/supabase-schema.test.ts | 229 +++++++++++------- 15 files changed, 437 insertions(+), 575 deletions(-) delete mode 100644 supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql delete mode 100644 supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql delete mode 100644 supabase/migrations/20260717010000_harden_rag_scalability_patch.sql diff --git a/docs/codebase-index.md b/docs/codebase-index.md index 2cfd095e0..a09628bd6 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -68,19 +68,17 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map ### API routes (`src/app/api/`) -| Area | Routes | Entry files | -| ------------- | ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| Answers | `/api/answer`, `/api/answer/stream`, `/api/answer-feedback` | `answer/route.ts`, `answer/stream/route.ts`, `answer-feedback/` | -| Search | `/api/search`, `/api/search/interaction`, `/api/search/universal` | `search/` | -| Upload | `/api/upload` | `upload/route.ts` | -| Documents | `/api/documents`, `/api/documents/[id]`, bulk/reindex, labels, reviews, search, signed URLs, summaries, table facts | `documents/` | -| Differentials | `/api/differentials`, `/api/differentials/[slug]`, `/api/differentials/presentations/[slug]` | `differentials/` | -| Medications | `/api/medications`, `/api/medications/[slug]` | `medications/` | -| Ingestion | `/api/ingestion/batches`, `/api/ingestion/jobs`, retry, quality | `ingestion/` | -| Registry | `/api/registry/records`, `/api/registry/records/[slug]` | `registry/records/` | -| Images | `/api/images/[id]/signed-url` | `images/[id]/signed-url/route.ts` | -| Ops | `/api/health`, `/api/health/ready`, `/api/setup-status`, `/api/local-project-id` | `health/`, `setup-status/`, `local-project-id/` | -| Eval / jobs | `/api/eval-cases`, `/api/jobs` | `eval-cases/`, `jobs/` | +| Area | Routes | Entry files | +| ----------- | ----------------------------------------------------------------------- | --------------------------------------------------------------- | +| Answers | `/api/answer`, `/api/answer/stream`, `/api/answer-feedback` | `answer/route.ts`, `answer/stream/route.ts`, `answer-feedback/` | +| Search | `/api/search`, `/api/search/interaction` | `search/` | +| Upload | `/api/upload` | `upload/route.ts` | +| Documents | CRUD, bulk, reindex, labels, search, summarize, table-facts, signed-url | `documents/` | +| Ingestion | batches, jobs, retry, quality | `ingestion/` | +| Registry | records CRUD | `registry/records/` | +| Images | signed URLs | `images/[id]/signed-url/route.ts` | +| Ops | health, setup-status, local-project-id | `health/`, `setup-status/`, `local-project-id/` | +| Eval / jobs | eval cases, job state | `eval-cases/`, `jobs/` | --- @@ -153,12 +151,12 @@ Structured map for AI agents and onboarding. For live routes, see `docs/site-map - **CLI:** `supabase/config.toml` — `indexing-v3-agent` function, `verify_jwt = false` - **Schema mirror:** `supabase/schema.sql` (reference; migrations are source of truth) -- **Migrations:** `supabase/migrations/*.sql` (chronological source of truth; do not hardcode a count) +- **Migrations:** `supabase/migrations/*.sql` (~90 files, May–Jul 2026) - **Drift policy:** `docs/supabase-migration-reconciliation.md` -### Schema tables +### Core tables -`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `document_title_words`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `image_caption_cache`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `rag_visual_eval_cases`, `rag_visual_eval_runs`, `rag_answer_feedback`, `clinical_registry_records`, `clinical_registry_record_sources`, `medication_records`, `differential_records`, `source_review_events`, `api_rate_limits`, `api_rate_limit_subjects`, `audit_logs`, `storage_cleanup_jobs` +`documents`, `document_pages`, `document_images`, `document_chunks`, `document_embedding_fields`, `document_index_units`, `document_table_facts`, `document_labels`, `document_summaries`, `document_sections`, `document_memory_cards`, `document_index_quality`, `ingestion_jobs`, `ingestion_job_stages`, `indexing_v3_agent_jobs`, `import_batches`, `rag_queries`, `rag_query_misses`, `rag_aliases`, `rag_response_cache`, `rag_retrieval_logs`, `clinical_registry_records`, `api_rate_limits`, `audit_logs`, `storage_cleanup_jobs` **Storage buckets:** `clinical-documents`, `clinical-images` (private) diff --git a/docs/site-map.md b/docs/site-map.md index 986979aba..3b7516445 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -2,7 +2,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current. -## Main product routes +## Main product pages - `/` - Main Clinical KB shell. Source: `src/app/page.tsx`. - `/differentials` - Differentials home and search surface. Source: `src/app/differentials/page.tsx`. @@ -799,11 +799,6 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/mockups/universal-search-command` - Route discovered from app directory Source: `src/app/mockups/universal-search-command/page.tsx`. - `/mockups/universal-search-redesign` - Route discovered from app directory Source: `src/app/mockups/universal-search-redesign/page.tsx`. -## Public utility route handlers - -- `/auth/callback` - Authentication callback handler. Source: `src/app/auth/callback/route.ts`. -- `/icons/[variant]` - Dynamically generated application icon handler. Source: `src/app/icons/[variant]/route.tsx`. - ## API routes - `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`. @@ -843,10 +838,14 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/api/setup-status` - Setup status. Source: `src/app/api/setup-status/route.ts`. - `/api/upload` - Upload endpoint. Source: `src/app/api/upload/route.ts`. +## App route handlers + +- `/auth/callback` - Route discovered from app directory Source: `src/app/auth/callback/route.ts`. + ## Redirects - `/applications` - Redirects to `/tools`. Source: `src/app/applications/route.ts`. -- `/differentials/presentations` - Redirects to `/differentials/presentations/[workflow-slug]`. Source: `src/app/differentials/presentations/route.ts`. +- `/differentials/presentations` - Redirects to `/differentials/presentations/[slug]`. Source: `src/app/differentials/presentations/route.ts`. - `/documents/source` - Redirects to `/documents/search`. Source: `src/app/documents/source/page.tsx`. - `/medications` - Redirects to `/?mode=prescribing`. Source: `src/app/medications/route.ts`. - `/mockups/favourites-hub` - Redirects to `/favourites`. Source: `src/app/mockups/favourites-hub/page.tsx`. diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 82d195306..6a82fae7b 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,4 +1,4 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { validateActionReference } from "./github-action-pins.mjs"; import { yamlBlock } from "./yaml-contract.mjs"; @@ -10,16 +10,10 @@ const failures = []; const expectedSupabaseCliVersion = "2.108.0"; const expectedSupabaseCliVersionPattern = expectedSupabaseCliVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -function discoverGitHubActionFiles(workflowRoot) { - const workflowDir = path.join(workflowRoot, ".github", "workflows"); - if (!existsSync(workflowDir)) return []; - return readdirSync(workflowDir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) - .map((entry) => path.join(workflowDir, entry.name)); -} - -for (const filePath of discoverGitHubActionFiles(process.cwd())) { - const fileName = path.relative(process.cwd(), filePath).replaceAll("\\", "/"); +for (const fileName of readdirSync(workflowDir) + .filter((name) => /\.ya?ml$/i.test(name)) + .sort()) { + const filePath = path.join(workflowDir, fileName); const lines = readFileSync(filePath, "utf8").split(/\r?\n/); lines.forEach((line, index) => { diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 1927fafa5..16b019bd8 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -16,7 +16,7 @@ const appDir = path.join(process.cwd(), "src", "app"); const siteMapPath = path.join(process.cwd(), "docs", "site-map.md"); const medicationSlugs = ["acamprosate"] as const; -type RouteKind = "page" | "handler"; +type RouteKind = "page" | "api"; type DiscoveredRoute = { route: string; @@ -31,24 +31,14 @@ type RedirectRoute = { type SiteMapData = { pageRoutes: DiscoveredRoute[]; - publicRouteHandlers: DiscoveredRoute[]; apiRoutes: DiscoveredRoute[]; appRouteHandlers: DiscoveredRoute[]; redirects: RedirectRoute[]; nonRoutedMockupArtifacts: string[]; }; -const productRouteHandlerPaths = new Set(["/applications", "/differentials/presentations", "/medications"]); - -const documentedRedirectTargets: Record = { - "/applications": "/tools", - "/differentials/presentations": "/differentials/presentations/[workflow-slug]", - "/medications": "/?mode=prescribing", -}; - const routeDescriptions: Record = { "/": "Main Clinical KB shell.", - "/applications": "Legacy application launcher redirect to Tools.", "/differentials": "Differentials home and search surface.", "/differentials/diagnoses": "Diagnosis stream.", "/differentials/diagnoses/[slug]": "Differential diagnosis detail.", @@ -82,11 +72,6 @@ const routeDescriptions: Record = { "/specifiers/map": "Psychiatric specifier family map.", }; -const publicRouteHandlerDescriptions: Record = { - "/auth/callback": "Authentication callback handler.", - "/icons/[variant]": "Dynamically generated application icon handler.", -}; - const apiDescriptions: Record = { "/api/answer": "Generate answer response.", "/api/answer/stream": "Streaming answer response.", @@ -143,16 +128,8 @@ function routeSegment(segment: string) { return segment; } -function isApiRoute(route: string) { - return route === "/api" || route.startsWith("/api/"); -} - function fileToRoute(filePath: string, kind: RouteKind) { - const suffix = path.basename(filePath); - const expectedSuffixes = kind === "page" ? ["page.tsx"] : ["route.ts", "route.tsx"]; - if (!expectedSuffixes.includes(suffix)) { - throw new Error(`Unsupported ${kind} route file: ${filePath}`); - } + const suffix = kind === "page" ? "page.tsx" : "route.ts"; const relative = toPosixPath(path.relative(appDir, filePath)); const withoutFile = relative.slice(0, -suffix.length).replace(/\/$/, ""); const segments = withoutFile.split("/").filter(Boolean).map(routeSegment).filter(Boolean); @@ -173,9 +150,8 @@ function collectFiles(root: string, targetFileName: string): string[] { } function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { - const targetFiles = kind === "page" ? ["page.tsx"] : ["route.ts", "route.tsx"]; - return targetFiles - .flatMap((targetFile) => collectFiles(appDir, targetFile)) + const targetFile = kind === "page" ? "page.tsx" : "route.ts"; + return collectFiles(appDir, targetFile) .map((file) => ({ route: fileToRoute(file, kind), file: toPosixPath(path.relative(process.cwd(), file)), @@ -183,12 +159,34 @@ function discoverRoutes(kind: RouteKind): DiscoveredRoute[] { .sort((left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file)); } +function isApiRoute(route: string) { + return route === "/api" || route.startsWith("/api/"); +} + +function extractRedirectTarget(source: string): string | null { + const pageRedirect = source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + if (pageRedirect) return pageRedirect; + + const urlRedirect = source.match(/NextResponse\.redirect\(\s*new URL\(\s*["']([^"']+)["']/)?.[1]; + if (urlRedirect) return urlRedirect; + + const pathnameRedirect = source.match(/\.pathname\s*=\s*["']([^"']+)["']/)?.[1]; + if (pathnameRedirect) return pathnameRedirect; + + // Template-literal destination builders with a stable route prefix. + const presentationsRedirect = source.match(/`(\/differentials\/presentations\/)\$\{/)?.[1]; + if (presentationsRedirect && source.includes("NextResponse.redirect")) { + return `${presentationsRedirect}[slug]`; + } + + return null; +} + function discoverRedirects(routes: DiscoveredRoute[]): RedirectRoute[] { return routes .map((route) => { const source = readFileSync(path.join(process.cwd(), route.file), "utf8"); - const target = - documentedRedirectTargets[route.route] ?? source.match(/\bredirect\(\s*["']([^"']+)["']\s*\)/)?.[1]; + const target = extractRedirectTarget(source); return target ? { ...route, target } : null; }) .filter((value): value is RedirectRoute => Boolean(value)) @@ -205,13 +203,20 @@ function discoverNonRoutedMockupArtifacts() { export function collectSiteMapData(): SiteMapData { const pageRoutes = discoverRoutes("page"); - const routeHandlers = discoverRoutes("handler"); - const publicRouteHandlers = routeHandlers.filter((route) => !isApiRoute(route.route)); + const routeHandlers = discoverRoutes("api"); + const apiRoutes = routeHandlers.filter((route) => isApiRoute(route.route)); + const nonApiHandlers = routeHandlers.filter((route) => !isApiRoute(route.route)); + const redirects = [...discoverRedirects(pageRoutes), ...discoverRedirects(nonApiHandlers)].sort( + (left, right) => left.route.localeCompare(right.route) || left.file.localeCompare(right.file), + ); + const redirectRoutes = new Set(redirects.map((redirect) => redirect.route)); + const appRouteHandlers = nonApiHandlers.filter((route) => !redirectRoutes.has(route.route)); + return { pageRoutes, - publicRouteHandlers, - apiRoutes: routeHandlers.filter((route) => isApiRoute(route.route)), - redirects: discoverRedirects([...pageRoutes, ...publicRouteHandlers]), + apiRoutes, + appRouteHandlers, + redirects, nonRoutedMockupArtifacts: discoverNonRoutedMockupArtifacts(), }; } @@ -392,9 +397,6 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ].includes(route.route), ); const mockupRoutes = data.pageRoutes.filter((route) => route.route.startsWith("/mockups")); - const publicUtilityRouteHandlers = data.publicRouteHandlers.filter( - (route) => !productRouteHandlerPaths.has(route.route), - ); const lines = [ "# Clinical KB Site Map", @@ -402,7 +404,7 @@ function renderSiteMapRaw(data = collectSiteMapData()) { "This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` to verify it is current.", "", ...section( - "Main product routes", + "Main product pages", productRoutes.map((route) => routeLine(route, routeDescriptions)), ), ...section("Mode/query routes", renderModeRoutes()), @@ -479,10 +481,6 @@ function renderSiteMapRaw(data = collectSiteMapData()) { ] : []), ]), - ...section( - "Public utility route handlers", - publicUtilityRouteHandlers.map((route) => routeLine(route, publicRouteHandlerDescriptions)), - ), ...section( "API routes", data.apiRoutes.map((route) => routeLine(route, apiDescriptions)), diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2ae175ea9..61fa12ce0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; +import { PwaLifecycle } from "@/components/pwa-lifecycle"; import { AuthProvider } from "@/lib/supabase/client"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; import { resolveMetadataBase } from "@/lib/metadata-base"; diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 6bff3b19c..dd267c8bd 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -605,7 +605,6 @@ export function ServicesNavigatorPage() { } sidebar={ setSelectedSlugs([])} diff --git a/src/lib/rag-candidate-sources.ts b/src/lib/rag-candidate-sources.ts index effed08f0..dd10ade39 100644 --- a/src/lib/rag-candidate-sources.ts +++ b/src/lib/rag-candidate-sources.ts @@ -45,6 +45,26 @@ import type { DocumentIndexUnitMatch, DocumentMemoryCard, SearchResult } from "@ // where telemetry is in scope, record the failing RPC + code so it shows up in rag_retrieval_logs. export type SupabaseRpcError = { message?: string; code?: string; details?: string; hint?: string } | null; +type AbortableQuery = PromiseLike & { + abortSignal?: (signal: AbortSignal) => PromiseLike; +}; + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +} + +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw abortReason(signal); +} + +async function resolveQuery(query: AbortableQuery, signal?: AbortSignal): Promise { + throwIfAborted(signal); + const pending = signal && typeof query.abortSignal === "function" ? query.abortSignal(signal) : query; + const result = await pending; + throwIfAborted(signal); + return result; +} + function legacyRankFields(versionedName: string) { if (versionedName === "match_document_chunks_v2") return ["similarity"]; if (versionedName === "match_documents_for_query_v2") return ["text_rank"]; @@ -81,15 +101,17 @@ export async function callVersionedRetrievalRpc versionedName: string, legacyName: string, args: Record, + signal?: AbortSignal, ): Promise<{ data: T | null; error: SupabaseRpcError }> { + type RpcResult = { data: T | null; error: SupabaseRpcError }; const client = supabase as unknown as { - rpc: (name: string, rpcArgs: Record) => Promise<{ data: T | null; error: SupabaseRpcError }>; + rpc: (name: string, rpcArgs: Record) => AbortableQuery; }; - const versioned = await client.rpc(versionedName, args); + const versioned = await resolveQuery(client.rpc(versionedName, args), signal); if (versioned && !isMissingRetrievalRpcError(versioned.error)) return versioned; const legacyArgs = { ...args }; delete legacyArgs.include_public; - const ownerResult = await client.rpc(legacyName, legacyArgs); + const ownerResult = await resolveQuery(client.rpc(legacyName, legacyArgs), signal); const ownerFilter = String(args.owner_filter ?? ""); if ( ownerResult.error || @@ -99,10 +121,13 @@ export async function callVersionedRetrievalRpc ) { return ownerResult; } - const publicResult = await client.rpc(legacyName, { - ...legacyArgs, - owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, - }); + const publicResult = await resolveQuery( + client.rpc(legacyName, { + ...legacyArgs, + owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, + }), + signal, + ); if (publicResult.error) return publicResult; return { data: mergeLegacyAccessRows( @@ -179,6 +204,7 @@ export async function searchTextChunkCandidates(args: { allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; + signal?: AbortSignal; }) { const runChunkText = async (queryText: string, matchCount: number) => { const accessScope = retrievalAccessScopeForArgs(args); @@ -192,6 +218,7 @@ export async function searchTextChunkCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(accessScope), }, + args.signal, ); // Report the error before returning empty so a schema drift on this // most-terminal lexical layer surfaces in hybrid_rpc_errors telemetry @@ -248,10 +275,13 @@ export async function searchTextChunkCandidates(args: { const primary = variants[0] ?? ""; let effectivePrimary = primary; if (primary) { - const { data: corrected } = await args.supabase.rpc("correct_clinical_query_terms", { - input_query: primary, - min_sim: 0.45, - }); + const { data: corrected } = await resolveQuery( + args.supabase.rpc("correct_clinical_query_terms", { + input_query: primary, + min_sim: 0.45, + }), + args.signal, + ); if (typeof corrected === "string" && corrected && corrected !== primary) { const correctedResults = await runChunkText(corrected, args.matchCount); if (correctedResults.length > 0) return correctedResults; @@ -376,6 +406,7 @@ async function fetchBestDocumentLookupChunks(args: { ownerId?: string; accessScope?: RetrievalAccessScope; allowGlobalSearch?: boolean; + signal?: AbortSignal; }) { const terms = documentLookupChunkTerms(args.query); const { data: rpcChunks, error: rpcError } = await callVersionedRetrievalRpc( @@ -388,6 +419,7 @@ async function fetchBestDocumentLookupChunks(args: { match_count: Math.max(args.limit * 3, 24), ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (!rpcError && rpcChunks?.length) { const ranked = (rpcChunks as DocumentLookupChunkRow[]) @@ -418,9 +450,8 @@ async function fetchBestDocumentLookupChunks(args: { .in("document_id", args.documentIds) .limit(Math.max(args.limit * 4, 24)); - const { data: matchedChunks, error: matchedError } = safeFilters - ? await baseQuery.or(safeFilters) - : await baseQuery.order("chunk_index", { ascending: true }); + const matchedQuery = safeFilters ? baseQuery.or(safeFilters) : baseQuery.order("chunk_index", { ascending: true }); + const { data: matchedChunks, error: matchedError } = await resolveQuery(matchedQuery, args.signal); if (!matchedError && matchedChunks?.length) { const ranked = (matchedChunks as DocumentLookupChunkRow[]) @@ -439,7 +470,7 @@ async function fetchBestDocumentLookupChunks(args: { .in("document_id", args.documentIds) .order("chunk_index", { ascending: true }) .limit(args.limit); - const { data: fallbackChunks, error: fallbackError } = await fallbackQuery; + const { data: fallbackChunks, error: fallbackError } = await resolveQuery(fallbackQuery, args.signal); if (fallbackError || !fallbackChunks?.length) return { chunks: [] as DocumentLookupChunkRow[], terms }; return { chunks: fallbackChunks as DocumentLookupChunkRow[], terms }; } @@ -451,6 +482,7 @@ async function fetchDocumentTitleAliasRows(args: { ownerId?: string; accessScope?: RetrievalAccessScope; documentIds?: string[]; + signal?: AbortSignal; }) { const terms = analyzeClinicalQuery(args.query) .documentTitleTerms.map((term) => term.replace(/[%_,]/g, " ").replace(/\s+/g, " ").trim()) @@ -474,7 +506,7 @@ async function fetchDocumentTitleAliasRows(args: { } if (args.documentIds?.length) query = query.in("id", args.documentIds); - const { data, error } = await query; + const { data, error } = await resolveQuery(query, args.signal); if (error || !data?.length) return [] as DocumentLookupRow[]; return (data as DocumentLookupRow[]).map((document) => ({ @@ -499,6 +531,7 @@ export async function searchDocumentLookupFastPath(args: { documentIds?: string[]; matchCount: number; telemetry?: SearchTelemetry; + signal?: AbortSignal; }): Promise { if (!args.ownerId) return [] as SearchResult[]; const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( @@ -515,6 +548,7 @@ export async function searchDocumentLookupFastPath(args: { match_count: matchCount, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_documents_for_query", error); if (error || !data?.length) return [] as DocumentLookupRow[]; @@ -533,6 +567,7 @@ export async function searchDocumentLookupFastPath(args: { ownerId: args.ownerId, accessScope: args.accessScope, documentIds: args.documentIds, + signal: args.signal, }); const documentsById = new Map(); for (const document of [...titleAliasDocuments, ...documentSets.flat()]) { @@ -566,6 +601,7 @@ export async function searchDocumentLookupFastPath(args: { limit: Math.max(args.matchCount, rankedDocuments.length * 4), ownerId: args.ownerId, accessScope: args.accessScope, + signal: args.signal, }); if (!chunks.length) return []; @@ -621,6 +657,7 @@ export async function loadChunksForMemoryCards( supabase: ReturnType, cards: DocumentMemoryCard[], accessScope: RetrievalAccessScope, + signal?: AbortSignal, ) { const documentIds = Array.from(new Set(cards.map((card) => card.document_id))).slice(0, 80); if (documentIds.length === 0) return [] as SearchResult[]; @@ -636,7 +673,7 @@ export async function loadChunksForMemoryCards( } else { documentQuery = documentQuery.is("owner_id", null); } - const { data: documents, error: documentsError } = await documentQuery; + const { data: documents, error: documentsError } = await resolveQuery(documentQuery, signal); if (documentsError || !documents?.length) return [] as SearchResult[]; const documentById = new Map(documents.map((document) => [document.id, document])); @@ -647,7 +684,7 @@ export async function loadChunksForMemoryCards( ), ).slice(0, 80); if (chunkIds.length === 0) return [] as SearchResult[]; - const { data: chunks, error: chunksError } = await supabase + const chunksQuery = supabase .from("document_chunks") .select( "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id", @@ -655,6 +692,7 @@ export async function loadChunksForMemoryCards( .in("id", chunkIds) .in("document_id", [...allowedDocumentIds]) .limit(chunkIds.length); + const { data: chunks, error: chunksError } = await resolveQuery(chunksQuery, signal); if (chunksError || !chunks?.length) return [] as SearchResult[]; const bestCardByChunk = new Map(); for (const card of cards) { @@ -783,6 +821,7 @@ export async function loadChunksForSignalMatches(args: { ownerId?: string; accessScope?: RetrievalAccessScope; cache?: ChunkLoadCache; + signal?: AbortSignal; }) { const bestMatchByChunk = new Map(); for (const match of args.matches) { @@ -801,11 +840,12 @@ export async function loadChunksForSignalMatches(args: { ids: chunkIds, scopeKey: cacheScopeKey, fetchRows: async (missingChunkIds) => { - const { data, error } = await args.supabase + const query = args.supabase .from("document_chunks") .select("id,document_id") .in("id", missingChunkIds) .limit(missingChunkIds.length); + const { data, error } = await resolveQuery(query, args.signal); return { data: data as ChunkScopeRow[] | null, error }; }, }); @@ -830,7 +870,7 @@ export async function loadChunksForSignalMatches(args: { } else { documentQuery = documentQuery.is("owner_id", null); } - const { data, error } = await documentQuery; + const { data, error } = await resolveQuery(documentQuery, args.signal); return { data: data as HydratedDocumentRow[] | null, error }; }, }); @@ -849,7 +889,7 @@ export async function loadChunksForSignalMatches(args: { ids: allowedChunkIds, scopeKey: cacheScopeKey, fetchRows: async (missingAllowedChunkIds) => { - const { data, error } = await args.supabase + const query = args.supabase .from("document_chunks") .select( "id,document_id,page_number,chunk_index,section_heading,section_path,heading_level,parent_heading,anchor_id,content,retrieval_synopsis,image_ids,index_generation_id", @@ -857,6 +897,7 @@ export async function loadChunksForSignalMatches(args: { .in("id", missingAllowedChunkIds) .in("document_id", [...allowedDocumentIds]) .limit(missingAllowedChunkIds.length); + const { data, error } = await resolveQuery(query, args.signal); return { data: data as HydratedChunkRow[] | null, error }; }, }); @@ -930,6 +971,7 @@ export async function searchTableFactCandidates(args: { matchCount: number; telemetry?: SearchTelemetry; cache?: ChunkLoadCache; + signal?: AbortSignal; }) { const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( 0, @@ -946,6 +988,7 @@ export async function searchTableFactCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_document_table_facts_text", error); if (error || !data?.length) return [] as TableFactRpcRow[]; @@ -987,6 +1030,7 @@ export async function searchTableFactCandidates(args: { ownerId: args.ownerId, accessScope: args.accessScope, cache: args.cache, + signal: args.signal, }); } @@ -1002,6 +1046,7 @@ export async function searchEmbeddingFieldCandidates(args: { matchCount: number; telemetry?: SearchTelemetry; cache?: ChunkLoadCache; + signal?: AbortSignal; }) { const { data, error } = await callVersionedRetrievalRpc( args.supabase, @@ -1015,6 +1060,7 @@ export async function searchEmbeddingFieldCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_document_embedding_fields_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -1052,6 +1098,7 @@ export async function searchEmbeddingFieldCandidates(args: { ownerId: args.ownerId, accessScope: args.accessScope, cache: args.cache, + signal: args.signal, }); } @@ -1067,6 +1114,7 @@ export async function searchIndexUnitCandidates(args: { matchCount: number; telemetry?: SearchTelemetry; cache?: ChunkLoadCache; + signal?: AbortSignal; }) { const { data, error } = await callVersionedRetrievalRpc( args.supabase, @@ -1080,6 +1128,7 @@ export async function searchIndexUnitCandidates(args: { document_filters: args.documentIds ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) recordHybridRpcError(args.telemetry, "match_document_index_units_hybrid", error); if (error || !data?.length) return [] as SearchResult[]; @@ -1119,6 +1168,7 @@ export async function searchIndexUnitCandidates(args: { ownerId: args.ownerId, accessScope: args.accessScope, cache: args.cache, + signal: args.signal, }); } @@ -1135,6 +1185,7 @@ export async function withMemoryBoostedCandidates(args: { documentIds?: string[]; matchCount: number; cardCache?: MemoryCardCache; + signal?: AbortSignal; }) { // A3: the memory-card fetch is invoked at several waterfall stages. Memoize per request, // scoped by owner/document filters because fetchMemoryCardsForQuery applies those filters. @@ -1157,13 +1208,19 @@ export async function withMemoryBoostedCandidates(args: { accessScope: args.accessScope, documentIds: args.documentIds, matchCount: effectiveMatchCount, + signal: args.signal, }); args.cardCache?.set(cacheKey, cardsPromise); } const cards = await cardsPromise; if (cards.length === 0) return { results: args.candidates, cards }; - const memoryChunkResults = await loadChunksForMemoryCards(args.supabase, cards, retrievalAccessScopeForArgs(args)); + const memoryChunkResults = await loadChunksForMemoryCards( + args.supabase, + cards, + retrievalAccessScopeForArgs(args), + args.signal, + ); const merged = mergeSearchResults(memoryChunkResults, args.candidates); return { results: applyMemoryCardBoosts(args.query, merged, cards), diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 0c2a8bbaf..2b689e5b3 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -417,9 +417,30 @@ const confidenceOrder = { } as const; /** Throw if aborted. */ +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +} + function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) { - throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + throw abortReason(signal); + } +} + +async function awaitWithAbortSignal(pending: Promise, signal?: AbortSignal): Promise { + if (!signal) return pending; + throwIfAborted(signal); + + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(abortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }); + try { + return await Promise.race([pending, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); } } @@ -1290,6 +1311,8 @@ export async function analyzeQueryWithClassifierFallback( // owner_filter retrieval will use so grounding can never see documents retrieval cannot. corpusGrounding?: { supabase: ReturnType; ownerFilter: string | null }; ownerId?: string | null; + signal?: AbortSignal; + skipClassifier?: boolean; }, ) { if ( @@ -1318,6 +1341,7 @@ export async function analyzeQueryWithClassifierFallback( supabase: opts.corpusGrounding.supabase, query, ownerFilter: opts.corpusGrounding.ownerFilter, + signal: opts.signal, }); if (grounding.verdict === "in_corpus_topic") { return { @@ -1343,7 +1367,8 @@ export async function analyzeQueryWithClassifierFallback( analysis = { ...analysis, corpusGrounding: "inconclusive" }; } - if (!analysis.needsClassifierFallback || !env.OPENAI_API_KEY) return analysis; + if (!analysis.needsClassifierFallback || !env.OPENAI_API_KEY || opts?.skipClassifier) return analysis; + throwIfAborted(opts?.signal); const memoKey = classifierVerdictMemoKey(query, analysis); const memoized = classifierVerdictMemo.get(memoKey); @@ -1361,10 +1386,11 @@ export async function analyzeQueryWithClassifierFallback( } try { - const verdict = await pending; + const verdict = await awaitWithAbortSignal(pending, opts?.signal); storeClassifierVerdictMemo(memoKey, verdict); return applyClassifierVerdict(analysis, verdict); } catch { + if (opts?.signal?.aborted) throw abortReason(opts.signal); // Transport/parse failures are deliberately NOT memoized: fall back to the deterministic // analysis for this request only, and let the next request retry the classifier. return analysis; @@ -1513,6 +1539,7 @@ export async function attachDocumentRankingMetadata( results: SearchResult[], ownerId?: string, cache = createDocumentRankingMetadataCache(), + signal?: AbortSignal, ) { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); if (documentIds.length === 0) return results; @@ -1542,7 +1569,7 @@ export async function attachDocumentRankingMetadata( document_summary: metadata.summary, }; }); - return attachIndexQualityMetadata(supabase, enriched, ownerId, cache); + return attachIndexQualityMetadata(supabase, enriched, ownerId, cache, signal); } const [metadataRows, indexedResults] = await Promise.all([ @@ -1550,8 +1577,12 @@ export async function attachDocumentRankingMetadata( supabase, ownerId, documentIds: missingDocumentIds, - }).catch(() => null), - attachIndexQualityMetadata(supabase, results, ownerId, cache), + signal, + }).catch(() => { + if (signal?.aborted) throw abortReason(signal); + return null; + }), + attachIndexQualityMetadata(supabase, results, ownerId, cache, signal), ]); if (!metadataRows) return indexedResults; @@ -1588,6 +1619,7 @@ async function attachIndexQualityMetadata( results: SearchResult[], ownerId?: string, cache = createDocumentRankingMetadataCache(), + signal?: AbortSignal, ): Promise { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); if (documentIds.length === 0) return results; @@ -1599,12 +1631,15 @@ async function attachIndexQualityMetadata( .select("document_id,owner_id,quality_score,extraction_quality,metrics,issues,updated_at") .in("document_id", missingDocumentIds); if (ownerId) query = query.eq("owner_id", ownerId); + if (signal) query = query.abortSignal(signal); const { data, error } = await query; + throwIfAborted(signal); if (error) return results; for (const documentId of missingDocumentIds) cache.indexQuality.set(documentId, null); for (const row of data ?? []) cache.indexQuality.set(row.document_id, row as SearchResult["indexing_quality"]); return withCachedIndexQuality(results, cache); } catch { + if (signal?.aborted) throw abortReason(signal); return results; } } @@ -1613,6 +1648,7 @@ async function attachIndexQualityMetadata( export async function attachPageVisualEvidence( supabase: ReturnType, results: SearchResult[], + signal?: AbortSignal, ): Promise { const documentIds = Array.from(new Set(results.map((result) => result.document_id))); const pageNumbers = Array.from( @@ -1632,7 +1668,7 @@ export async function attachPageVisualEvidence( const selectColumns = "id,document_id,page_number,storage_path,caption,bbox,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata"; - const [pageData, directData] = await Promise.all([ + const pageQuery = pageNumbers.length > 0 ? supabase .from("document_images") @@ -1643,7 +1679,8 @@ export async function attachPageVisualEvidence( .neq("image_type", "logo_decorative") .order("clinical_relevance_score", { ascending: false }) .limit(80) - : Promise.resolve({ data: [], error: null }), + : null; + const directQuery = sourceImageIds.length > 0 ? supabase .from("document_images") @@ -1652,8 +1689,20 @@ export async function attachPageVisualEvidence( .eq("searchable", true) .neq("image_type", "logo_decorative") .limit(sourceImageIds.length) + : null; + const [pageData, directData] = await Promise.all([ + pageQuery + ? signal && typeof pageQuery.abortSignal === "function" + ? pageQuery.abortSignal(signal) + : pageQuery + : Promise.resolve({ data: [], error: null }), + directQuery + ? signal && typeof directQuery.abortSignal === "function" + ? directQuery.abortSignal(signal) + : directQuery : Promise.resolve({ data: [], error: null }), ]); + throwIfAborted(signal); const data = [...(pageData.data ?? []), ...(directData.data ?? [])]; if ((pageData.error && directData.error) || data.length === 0) return results; @@ -2225,6 +2274,8 @@ async function prepareCoverageGateResults(args: { queryClass: RagQueryClass; telemetry: SearchTelemetry; metadataCache: DocumentRankingMetadataCache; + includeVisualEvidence?: boolean; + signal?: AbortSignal; }) { const startedAt = Date.now(); const candidates = await attachDocumentRankingMetadata( @@ -2232,18 +2283,20 @@ async function prepareCoverageGateResults(args: { args.candidates, args.ownerId, args.metadataCache, + args.signal, ); - let results = await attachPageVisualEvidence( - args.supabase, - selectRankedRetrievalResults({ - query: args.query, - queryClass: args.queryClass, - candidates, - topK: args.topK, - maxResultsPerDocument: args.maxResultsPerDocument, - telemetry: args.telemetry, - }), - ); + const rankedResults = selectRankedRetrievalResults({ + query: args.query, + queryClass: args.queryClass, + candidates, + topK: args.topK, + maxResultsPerDocument: args.maxResultsPerDocument, + telemetry: args.telemetry, + }); + let results = + args.includeVisualEvidence === false + ? rankedResults + : await attachPageVisualEvidence(args.supabase, rankedResults, args.signal); results = applySecondStageRerankIfNeeded({ queryClass: args.queryClass, results, @@ -2362,6 +2415,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { } const indexingVersionAtRetrievalStart = await cacheIndexingVersion(args, { forceRefresh: true }); const supabase = createAdminClient(); + const attachSearchVisualEvidence = (results: SearchResult[]) => + args.lexicalOnly ? Promise.resolve(results) : attachPageVisualEvidence(supabase, results, args.signal); // When the provider is source-only (offline mode, or auto mode without a usable key) we must // never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only. const sourceOnlyRetrieval = isSourceOnlyMode(); @@ -2398,6 +2453,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const queryAnalysis = await analyzeQueryWithClassifierFallback(retrievalQuery, analyzeClinicalQuery(retrievalQuery), { corpusGrounding: corpusGroundingScope, ownerId: args.ownerId, + signal: args.signal, + skipClassifier: args.lexicalOnly, }); throwIfAborted(args.signal); if (modeQueryClass) queryAnalysis.queryClass = modeQueryClass; @@ -2409,7 +2466,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const telemetry = createSearchTelemetry(retrievalQuery, queryClassification.queryClass); if (queryAnalysis.corpusGrounding) telemetry.corpus_grounding = queryAnalysis.corpusGrounding; - const ragAliases = await fetchEnabledRagAliases(supabase, args.ownerId, args.accessScope); + const ragAliases = await fetchEnabledRagAliases(supabase, args.ownerId, args.accessScope, args.signal); const ragAliasExpansions = selectRagAliasExpansions(retrievalQuery, ragAliases); telemetry.rag_alias_count = ragAliases.length; telemetry.rag_alias_expansion_count = ragAliasExpansions.length; @@ -2449,10 +2506,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { // in searchTextChunkCandidates). Only reached for would-be-unsupported queries, so it adds no // hot-path cost; `typoCorrected` guards against recursion. if (!args.typoCorrected && !sourceOnlyRetrieval) { - const { data: corrected } = await supabase.rpc("correct_clinical_query_terms", { + let correctionQuery = supabase.rpc("correct_clinical_query_terms", { input_query: retrievalQuery, min_sim: 0.45, }); + if (args.signal) correctionQuery = correctionQuery.abortSignal(args.signal); + const { data: corrected } = await correctionQuery; + throwIfAborted(args.signal); if (typeof corrected === "string" && corrected && corrected.toLowerCase() !== retrievalQuery.toLowerCase()) { return searchChunksWithTelemetry({ ...args, query: corrected, typoCorrected: true }); } @@ -2487,6 +2547,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { allowGlobalSearch: args.allowGlobalSearch, matchCount: textCandidateCount, telemetry, + signal: args.signal, }); telemetry.text_candidate_count = textData.length; telemetry.text_fast_path_latency_ms = Date.now() - textRpcStartedAt; @@ -2504,6 +2565,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { textData as SearchResult[], args.ownerId, documentRankingMetadataCache, + args.signal, ); expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, textCandidates); const baseTextResults = selectRankedRetrievalResults({ @@ -2517,7 +2579,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { const baseTextFastPath = decideTextFastPath(args.query, baseTextResults, queryClassification.queryClass); if (!args.forceEmbedding && shouldReturnBeforeMemory(queryClassification.queryClass, baseTextFastPath)) { - textFastResults = await attachPageVisualEvidence(supabase, baseTextResults); + textFastResults = await attachSearchVisualEvidence(baseTextResults); textFastResults = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, results: textFastResults, @@ -2541,6 +2603,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, + signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2558,7 +2621,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }); - textFastResults = await attachPageVisualEvidence(supabase, textFastResults); + textFastResults = await attachSearchVisualEvidence(textFastResults); textFastResults = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, results: textFastResults, @@ -2593,6 +2656,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { matchCount: Math.min(candidateCount, 48), telemetry, cache: chunkLoadCache, + signal: args.signal, }); const tableFactLatencyMs = Date.now() - tableFactStartedAt; telemetry.supabase_rpc_latency_ms += tableFactLatencyMs; @@ -2616,6 +2680,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, telemetry, + signal: args.signal, }); const documentLookupLatencyMs = Date.now() - documentLookupStartedAt; telemetry.supabase_rpc_latency_ms += documentLookupLatencyMs; @@ -2631,6 +2696,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { mergeSearchResults(documentLookupData, textFastResults), args.ownerId, documentRankingMetadataCache, + args.signal, ); expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, documentLookupCandidates); const memoryBoost = await withMemoryBoostedCandidates({ @@ -2642,6 +2708,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, + signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2656,8 +2723,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { topScore: Math.max(telemetry.memory_top_score ?? 0, ...memoryBoost.cards.map(memoryCardChunkScore)), }, ); - let documentLookupResults = await attachPageVisualEvidence( - supabase, + let documentLookupResults = await attachSearchVisualEvidence( selectRankedRetrievalResults({ query: retrievalQuery, queryClass: queryClassification.queryClass, @@ -2707,6 +2773,8 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { queryClass: queryClassification.queryClass, telemetry, metadataCache: documentRankingMetadataCache, + includeVisualEvidence: !args.lexicalOnly, + signal: args.signal, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); @@ -2780,6 +2848,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { matchCount: Math.min(candidateCount, 48), telemetry, cache: chunkLoadCache, + signal: args.signal, }); return { candidates, latencyMs: Date.now() - startedAt }; })(), @@ -2796,6 +2865,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { matchCount: Math.min(candidateCount, 64), telemetry, cache: chunkLoadCache, + signal: args.signal, }); return { candidates, latencyMs: Date.now() - startedAt }; })(), @@ -2813,6 +2883,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filters: documentFilterList ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -2863,6 +2934,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { merged, args.ownerId, documentRankingMetadataCache, + args.signal, ); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -2874,6 +2946,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, + signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2890,6 +2963,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }), + args.signal, ); results = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, @@ -2920,6 +2994,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filter: documentFilter ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) throw new Error(error.message); @@ -2947,6 +3022,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, documentRankingMetadataCache, + args.signal, ); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -2958,6 +3034,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { documentIds: documentFilterList, matchCount: candidateCount, cardCache: memoryCardCache, + signal: args.signal, }); telemetry.memory_card_count = Math.max(telemetry.memory_card_count ?? 0, memoryBoost.cards.length); telemetry.memory_top_score = Math.max( @@ -2974,6 +3051,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }), + args.signal, ); results = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index ed2fe4681..da865fcb0 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-17T14:08:59.595Z", + "generated_at": "2026-07-17T15:00:51.747Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "e93fe90cae38654e77b7be0f81e5a0c90b0c7db2fd37d76e32f4f52a089eff45", - "replay_seconds": 30, + "schema_sha256": "bde3b7c60872a8c4493555e6670c30ac777a862a12567ef2091dfc050c86240e", + "replay_seconds": 77, "snapshot": { "views": [ { @@ -5290,6 +5290,12 @@ "table": "document_table_facts", "def_hash": "9ac4c08564d3f549df99a1e543875cb8" }, + { + "def": "CREATE INDEX document_title_words_document_id_idx ON public.document_title_words USING btree (document_id)", + "name": "document_title_words_document_id_idx", + "table": "document_title_words", + "def_hash": "003cf77523b819a06ff50cf8df6fcf4b" + }, { "def": "CREATE UNIQUE INDEX document_title_words_pkey ON public.document_title_words USING btree (word, document_id)", "name": "document_title_words_pkey", @@ -5350,6 +5356,12 @@ "table": "documents", "def_hash": "fc27ae2194373897e2f316e1cac47fb0" }, + { + "def": "CREATE INDEX documents_registry_projection_lookup_idx ON public.documents USING btree (((metadata ->> 'registry_record_kind'::text)), ((metadata ->> 'registry_record_id'::text))) WHERE ((metadata ->> 'source_kind'::text) = 'registry_record'::text)", + "name": "documents_registry_projection_lookup_idx", + "table": "documents", + "def_hash": "2c19fee4cac85c85d9ec69f179a6cb79" + }, { "def": "CREATE INDEX documents_search_idx ON public.documents USING gin (search_tsv)", "name": "documents_search_idx", @@ -6328,6 +6340,11 @@ "name": "documents_require_publication_approval", "table": "documents" }, + { + "def": "CREATE TRIGGER documents_sync_title_words AFTER INSERT OR DELETE OR UPDATE OF title, status, owner_id ON public.documents FOR EACH ROW EXECUTE FUNCTION public.sync_document_title_words()", + "name": "documents_sync_title_words", + "table": "documents" + }, { "def": "CREATE TRIGGER documents_updated_at BEFORE UPDATE ON public.documents FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "documents_updated_at", @@ -6448,10 +6465,9 @@ }, { "acl": [ - "postgres=X/postgres", - "service_role=X/postgres" + "postgres=X/postgres" ], - "def_hash": "a0e9e41b95c9eeff7957dd292f3dd3fa", + "def_hash": "7104ee6e947fcca68fdebdf2fa878339", "signature": "public.cleanup_registry_corpus_document()" }, { @@ -6509,6 +6525,14 @@ "def_hash": "e263f3819b05177abcf2eedd3f468991", "signature": "public.consume_api_subject_rate_limit(text,text,integer,integer)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "833c00fa1743026d9269b9af28e0b86f", + "signature": "public.consume_summary_rate_limits_atomic(uuid,text,integer,integer,integer,integer,integer,integer)" + }, { "acl": [ "postgres=X/postgres", @@ -6530,7 +6554,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a5ebdd1ec14008cdf0c8601a32b4d24c", + "def_hash": "cb65883a561cb1f5cd2247213f417a41", "signature": "public.correct_clinical_query_terms(text,real)" }, { @@ -6538,7 +6562,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "675d521a0746befe09e68e47986c6355", + "def_hash": "a6f0e7c27da08e47e2c01dce4ab5d58d", "signature": "public.default_privileges_status(text,text)" }, { @@ -7056,10 +7080,9 @@ }, { "acl": [ - "postgres=X/postgres", - "service_role=X/postgres" + "postgres=X/postgres" ], - "def_hash": "582d35a5082ff0e4879db461294404fd", + "def_hash": "6b51ece12494d136b39977d8532c013c", "signature": "public.sync_document_title_words()" }, { @@ -7573,11 +7596,21 @@ "name": "document_title_words_document_id_fkey", "table": "document_title_words" }, + { + "def": "CHECK ((word = lower(word)))", + "name": "document_title_words_lowercase", + "table": "document_title_words" + }, { "def": "PRIMARY KEY (word, document_id)", "name": "document_title_words_pkey", "table": "document_title_words" }, + { + "def": "CHECK (((length(word) >= 4) AND (length(word) <= 40)))", + "name": "document_title_words_word_length", + "table": "document_title_words" + }, { "def": "FOREIGN KEY (import_batch_id) REFERENCES public.import_batches(id) ON DELETE SET NULL", "name": "documents_import_batch_id_fkey", diff --git a/supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql b/supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql deleted file mode 100644 index 45d3e1182..000000000 --- a/supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql +++ /dev/null @@ -1,144 +0,0 @@ --- Cascade deletion trigger for registry records to clean up RAG corpus documents -create or replace function public.cleanup_registry_corpus_document() -returns trigger -language plpgsql -security definer -set search_path = public, pg_catalog, pg_temp -as $$ -begin - delete from public.documents - where metadata->>'source_kind' = 'registry_record' - and (metadata->>'registry_record_id')::uuid = OLD.id; - return OLD; -end; -$$; - -drop trigger if exists clinical_registry_records_delete_cleanup on public.clinical_registry_records; -create trigger clinical_registry_records_delete_cleanup - after delete on public.clinical_registry_records - for each row execute function public.cleanup_registry_corpus_document(); - -drop trigger if exists medication_records_delete_cleanup on public.medication_records; -create trigger medication_records_delete_cleanup - after delete on public.medication_records - for each row execute function public.cleanup_registry_corpus_document(); - -drop trigger if exists differential_records_delete_cleanup on public.differential_records; -create trigger differential_records_delete_cleanup - after delete on public.differential_records - for each row execute function public.cleanup_registry_corpus_document(); - - --- Scalable Spelling Corrector Vocabulary Indexing -create table if not exists public.document_title_words ( - word text not null, - document_id uuid not null references public.documents(id) on delete cascade, - primary key (word, document_id) -); - -create index if not exists document_title_words_word_trgm_idx - on public.document_title_words using gin (word extensions.gin_trgm_ops); - -alter table public.document_title_words enable row level security; -revoke all on public.document_title_words from anon, authenticated; -grant select, insert, update, delete on table public.document_title_words to service_role; - --- Populate table from existing documents -insert into public.document_title_words (word, document_id) -select distinct lower(w), d.id -from public.documents d, - lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as w -where d.status = 'indexed' - and length(w) between 4 and 40 -on conflict do nothing; - --- Sync trigger on documents to keep title words vocabulary updated -create or replace function public.sync_document_title_words() -returns trigger -language plpgsql -security definer -set search_path = public, pg_catalog, pg_temp -as $$ -begin - if TG_OP = 'DELETE' or (TG_OP = 'UPDATE' and OLD.status = 'indexed' and NEW.status <> 'indexed') then - delete from public.document_title_words where document_id = OLD.id; - end if; - - if (TG_OP = 'INSERT' and NEW.status = 'indexed') or - (TG_OP = 'UPDATE' and NEW.status = 'indexed' and (OLD.status <> 'indexed' or OLD.title <> NEW.title)) then - - if TG_OP = 'UPDATE' then - delete from public.document_title_words where document_id = NEW.id; - end if; - - insert into public.document_title_words (word, document_id) - select distinct lower(w), NEW.id - from unnest(regexp_split_to_array(lower(NEW.title), '[^a-z]+')) as w - where length(w) between 4 and 40 - on conflict do nothing; - end if; - - return null; -end; -$$; - -drop trigger if exists documents_sync_title_words on public.documents; -create trigger documents_sync_title_words - after insert or update or delete on public.documents - for each row execute function public.sync_document_title_words(); - --- Optimize spelling corrector to query index table -CREATE OR REPLACE FUNCTION public.correct_clinical_query_terms(input_query text, min_sim real DEFAULT 0.45) - RETURNS text - LANGUAGE plpgsql - STABLE SECURITY DEFINER - SET search_path TO 'public', 'extensions', 'pg_temp' -AS $function$ -declare - vocab text[]; - tokens text[]; - tok text; - best text; - best_sim real; - corrected text[] := array[]::text[]; - changed boolean := false; -begin - if input_query is null or length(trim(input_query)) = 0 then - return input_query; - end if; - - select array_agg(distinct term) into vocab - from ( - select lower(alias) as term from public.rag_aliases where enabled and length(alias) between 4 and 40 - union - select lower(canonical) from public.rag_aliases where enabled and length(canonical) between 4 and 40 - union - select word from public.document_title_words where length(word) between 4 and 40 - ) t; - - tokens := regexp_split_to_array(lower(input_query), '\s+'); - foreach tok in array tokens loop - if length(tok) < 4 or tok = any(vocab) then - corrected := corrected || tok; - continue; - end if; - best := null; - best_sim := 0; - select v, similarity(v, tok) into best, best_sim - from unnest(vocab) as v - order by similarity(v, tok) desc - limit 1; - if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then - corrected := corrected || best; - changed := true; - else - corrected := corrected || tok; - end if; - end loop; - - if changed then - return array_to_string(corrected, ' '); - end if; - return input_query; -end; -$function$; diff --git a/supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql b/supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql deleted file mode 100644 index c0523bf60..000000000 --- a/supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Migration to add GIN trigram index on document_table_facts text fields for performance optimization -create index if not exists document_table_facts_text_trgm_idx - on public.document_table_facts using gin ( - lower( - coalesce(table_title, '') || ' ' || - coalesce(row_label, '') || ' ' || - coalesce(clinical_parameter, '') || ' ' || - coalesce(threshold_value, '') || ' ' || - coalesce(action, '') - ) extensions.gin_trgm_ops - ); diff --git a/supabase/migrations/20260717010000_harden_rag_scalability_patch.sql b/supabase/migrations/20260717010000_harden_rag_scalability_patch.sql deleted file mode 100644 index 1a6e74243..000000000 --- a/supabase/migrations/20260717010000_harden_rag_scalability_patch.sql +++ /dev/null @@ -1,116 +0,0 @@ --- Forward-only hardening for the registry cleanup and RAG scalability WIP. --- This intentionally corrects the earlier July 14 migrations without assuming --- whether either version has already been applied in an external environment. - -create index if not exists rag_aliases_canonical_trgm_idx - on public.rag_aliases using gin (lower(canonical) extensions.gin_trgm_ops); - -create or replace function public.cleanup_registry_corpus_document() -returns trigger -language plpgsql -security definer -set search_path = public, pg_catalog, pg_temp -as $$ -begin - delete from public.documents - where metadata->>'source_kind' = 'registry_record' - and metadata->>'registry_record_id' = OLD.id::text - and metadata->>'registry_record_kind' = case TG_TABLE_NAME - when 'clinical_registry_records' then to_jsonb(OLD)->>'kind' - when 'medication_records' then 'medication' - when 'differential_records' then 'differential' - else null - end; - return OLD; -end; -$$; - -revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated; -revoke execute on function public.sync_document_title_words() from public, anon, authenticated; - -create or replace function public.correct_clinical_query_terms(input_query text, min_sim real default 0.45) -returns text -language plpgsql -stable security definer -set search_path to 'public', 'extensions', 'pg_temp' -set pg_trgm.similarity_threshold = 0.3 -as $$ -declare - tokens text[]; - tok text; - best text; - best_sim real; - corrected text[] := array[]::text[]; - changed boolean := false; -begin - if min_sim is null or min_sim < 0.3 or min_sim > 1 then - raise exception 'min_sim must be between 0.3 and 1.0' using errcode = '22023'; - end if; - - if input_query is null or length(trim(input_query)) = 0 then - return input_query; - end if; - - tokens := regexp_split_to_array(lower(input_query), '\s+'); - foreach tok in array tokens loop - if length(tok) < 4 then - corrected := corrected || tok; - continue; - end if; - - best := null; - best_sim := 0; - select candidate.term, similarity(candidate.term, tok) - into best, best_sim - from ( - ( - select lower(alias) as term - from public.rag_aliases - where enabled - and length(alias) between 4 and 40 - and lower(alias) % tok - order by similarity(lower(alias), tok) desc, lower(alias) - limit 32 - ) - union all - ( - select lower(canonical) as term - from public.rag_aliases - where enabled - and length(canonical) between 4 and 40 - and lower(canonical) % tok - order by similarity(lower(canonical), tok) desc, lower(canonical) - limit 32 - ) - union all - ( - select word as term - from public.document_title_words - where length(word) between 4 and 40 - and word % tok - order by similarity(word, tok) desc, word - limit 32 - ) - ) candidate - order by similarity(candidate.term, tok) desc, candidate.term - limit 1; - - if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then - corrected := corrected || best; - changed := true; - else - corrected := corrected || tok; - end if; - end loop; - - if not changed then - return input_query; - end if; - return array_to_string(corrected, ' '); -end; -$$; - -revoke execute on function public.correct_clinical_query_terms(text, real) from public, anon, authenticated; -grant execute on function public.correct_clinical_query_terms(text, real) to service_role; - -drop index if exists public.document_table_facts_text_trgm_idx; diff --git a/supabase/schema.sql b/supabase/schema.sql index 76c79e056..b3f440e2a 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -848,8 +848,6 @@ create index if not exists rag_aliases_type_enabled_idx on public.rag_aliases(alias_type, enabled); create index if not exists rag_aliases_alias_trgm_idx on public.rag_aliases using gin (lower(alias) gin_trgm_ops); -create index if not exists rag_aliases_canonical_trgm_idx - on public.rag_aliases using gin (lower(canonical) gin_trgm_ops); create index if not exists rag_response_cache_expiry_idx on public.rag_response_cache(expires_at); create index if not exists rag_response_cache_owner_kind_idx @@ -3475,9 +3473,9 @@ CREATE OR REPLACE FUNCTION public.correct_clinical_query_terms(input_query text, LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path TO 'public', 'extensions', 'pg_temp' - SET pg_trgm.similarity_threshold = 0.3 AS $function$ declare + vocab text[]; tokens text[]; tok text; best text; @@ -3485,10 +3483,6 @@ declare corrected text[] := array[]::text[]; changed boolean := false; begin - if min_sim is null or min_sim < 0.3 or min_sim > 1 then - raise exception 'min_sim must be between 0.3 and 1.0' using errcode = '22023'; - end if; - if input_query is null or length(trim(input_query)) = 0 then return input_query; end if; @@ -3510,45 +3504,15 @@ begin tokens := regexp_split_to_array(lower(input_query), '\s+'); foreach tok in array tokens loop - if length(tok) < 4 then + if length(tok) < 4 or tok = any(vocab) then corrected := corrected || tok; continue; end if; best := null; best_sim := 0; - select candidate.term, similarity(candidate.term, tok) - into best, best_sim - from ( - ( - select lower(alias) as term - from public.rag_aliases - where enabled - and length(alias) between 4 and 40 - and lower(alias) % tok - order by similarity(lower(alias), tok) desc, lower(alias) - limit 32 - ) - union all - ( - select lower(canonical) as term - from public.rag_aliases - where enabled - and length(canonical) between 4 and 40 - and lower(canonical) % tok - order by similarity(lower(canonical), tok) desc, lower(canonical) - limit 32 - ) - union all - ( - select word as term - from public.document_title_words - where length(word) between 4 and 40 - and word % tok - order by similarity(word, tok) desc, word - limit 32 - ) - ) candidate - order by similarity(candidate.term, tok) desc, candidate.term + select v, similarity(v, tok) into best, best_sim + from unnest(vocab) as v + order by similarity(v, tok) desc limit 1; if best is not null and best_sim >= min_sim and best <> tok and length(best) >= length(tok) then corrected := corrected || best; diff --git a/tests/site-map.test.ts b/tests/site-map.test.ts index 4e1d24dfe..19907503d 100644 --- a/tests/site-map.test.ts +++ b/tests/site-map.test.ts @@ -52,50 +52,15 @@ describe("tracked sitemap", () => { expect(siteMap).toBe(await renderSiteMap()); }); - it("documents every app page, public route handler, and API route", () => { + it("documents every app page route and API route", () => { const data = collectSiteMapData(); for (const pageRoute of data.pageRoutes) expectDocumentedRoute(pageRoute.route); - for (const routeHandler of data.publicRouteHandlers) expectDocumentedRoute(routeHandler.route); for (const apiRoute of data.apiRoutes) expectDocumentedRoute(apiRoute.route); for (const handler of data.appRouteHandlers) expectDocumentedRoute(handler.route); for (const redirect of data.redirects) expectDocumentedRoute(redirect.route); }); - it("keeps public redirect handlers in product routes and API handlers in the API section", () => { - const data = collectSiteMapData(); - const productSection = siteMap.slice( - siteMap.indexOf("## Main product routes"), - siteMap.indexOf("## Mode/query routes"), - ); - const apiSection = siteMap.slice(siteMap.indexOf("## API routes"), siteMap.indexOf("## Redirects")); - const expectedProductHandlers = [ - ["/applications", "src/app/applications/route.ts", "/tools"], - [ - "/differentials/presentations", - "src/app/differentials/presentations/route.ts", - "/differentials/presentations/[workflow-slug]", - ], - ["/medications", "src/app/medications/route.ts", "/?mode=prescribing"], - ] as const; - - expect(data.apiRoutes.every((route) => route.route === "/api" || route.route.startsWith("/api/"))).toBe(true); - expect(data.publicRouteHandlers.some((route) => route.route === "/auth/callback")).toBe(true); - expect(data.publicRouteHandlers).toContainEqual({ - route: "/icons/[variant]", - file: "src/app/icons/[variant]/route.tsx", - }); - expect(apiSection).not.toContain("`/icons/[variant]`"); - - for (const [route, file, target] of expectedProductHandlers) { - expect(data.publicRouteHandlers).toContainEqual({ route, file }); - expect(data.apiRoutes).not.toContainEqual({ route, file }); - expect(data.redirects).toContainEqual({ route, file, target }); - expect(productSection).toContain(`\`${route}\``); - expect(apiSection).not.toContain(`\`${route}\``); - } - }); - it("documents seeded dynamic slugs", () => { for (const service of serviceRecords) expectDocumentedRoute(service.slug); for (const form of formRecords) expectDocumentedRoute(form.slug); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index c0f6dcfd9..afbf529e7 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -129,7 +129,7 @@ const deleteDocumentIfIdleMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const defaultAclAssertionMigration = readFileSync( - new URL("../supabase/migrations/20260717161000_assert_supabase_admin_default_privileges.sql", import.meta.url), + new URL("../supabase/migrations/20260717173000_reassert_supabase_admin_default_privileges.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const defaultAclRoleBootstrap = readFileSync(new URL("../supabase/roles.sql", import.meta.url), "utf8").replace( @@ -184,6 +184,18 @@ const responseCacheRetentionReconciliationMigration = readFileSync( new URL("../supabase/migrations/20260713201542_consolidate_rag_response_cache_retention.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const registryProjectionCleanupMigration = readFileSync( + new URL("../supabase/migrations/20260717170000_registry_projection_cleanup.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const publicTitleCorrectorMigration = readFileSync( + new URL("../supabase/migrations/20260717171000_public_title_corrector.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const atomicSummaryRateLimitsMigration = readFileSync( + new URL("../supabase/migrations/20260717172000_atomic_summary_rate_limits.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const liveDatabaseDriftMigration = readFileSync( new URL("../supabase/migrations/20260705230000_reconcile_live_database_drift.sql", import.meta.url), "utf8", @@ -203,27 +215,6 @@ const retrievalPlanCacheMigration = readFileSync( new URL("../supabase/migrations/20260711120000_retrieval_fn_plan_cache_mode.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); -const patchRagAndCorrectorScalabilityMigration = readFileSync( - new URL("../supabase/migrations/20260714180000_patch_rag_and_corrector_scalability.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const documentTableFactsTrgmMigration = readFileSync( - new URL("../supabase/migrations/20260714190000_document_table_facts_trgm_idx.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); -const hardenRagScalabilityPatchMigration = readFileSync( - new URL("../supabase/migrations/20260717010000_harden_rag_scalability_patch.sql", import.meta.url), - "utf8", -).replace(/\s+/g, " "); - -function finalSqlSegment(sql: string, startMarker: string, endMarker: string) { - const normalized = sql.toLowerCase(); - const start = normalized.lastIndexOf(startMarker.toLowerCase()); - if (start < 0) throw new Error(`Missing SQL marker: ${startMarker}`); - const end = normalized.indexOf(endMarker.toLowerCase(), start); - if (end < 0) throw new Error(`Missing SQL marker after ${startMarker}: ${endMarker}`); - return normalized.slice(start, end); -} function extractTextChunkFunction(sql: string) { const start = sql.indexOf("function public.match_document_chunks_text"); @@ -1205,13 +1196,22 @@ describe("Supabase Preview replay guards", () => { it("fails closed on effective supabase_admin default ACLs", () => { for (const sql of [schema, defaultAclAssertionMigration]) { - expect(sql).toContain("create or replace function public.default_privileges_status("); - expect(sql).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); - expect(sql).toContain("pg_catalog.aclexplode(ea.acl)"); - expect(sql).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); - expect(sql).toContain("entry like 'table:PUBLIC:%'"); - expect(sql).toContain("entry like 'sequence:PUBLIC:%'"); - expect(sql).toContain("entry = 'function:PUBLIC:execute'"); + const statusStart = sql.lastIndexOf("create or replace function public.default_privileges_status("); + const statusEnd = sql.indexOf("$$;", statusStart); + const statusFunction = sql.slice(statusStart, statusEnd); + + expect(statusStart).toBeGreaterThanOrEqual(0); + expect(statusEnd).toBeGreaterThan(statusStart); + expect(statusFunction).toContain("pg_catalog.acldefault(ot.object_code, v_role_oid)"); + expect(statusFunction).toContain("pg_catalog.aclexplode(ea.acl)"); + expect(statusFunction).toContain("bool_or(grantee not in (p_role_name, 'postgres', 'service_role'))"); + expect(statusFunction).toContain("privilege.is_grantable"); + expect(statusFunction).toContain("coalesce(bool_or(is_grantable), false)"); + expect(statusFunction).toContain("into v_entries, v_has_unexpected_grantee, v_has_grantable"); + expect(statusFunction).toContain("not v_has_grantable"); + expect(statusFunction).toContain("entry like 'table:PUBLIC:%'"); + expect(statusFunction).toContain("entry like 'sequence:PUBLIC:%'"); + expect(statusFunction).toContain("entry = 'function:PUBLIC:execute'"); expect(sql).toContain("message = 'Unsafe supabase_admin default privileges; migration blocked.'"); expect(sql).toContain("Run these six statements as supabase_admin, then retry the migration:"); } @@ -1219,7 +1219,7 @@ describe("Supabase Preview replay guards", () => { const migrationFiles = readdirSync(migrationDirectoryUrl) .filter((fileName) => /^\d+_.+\.sql$/.test(fileName)) .sort(); - expect(migrationFiles.at(-1)).toBe("20260717161000_assert_supabase_admin_default_privileges.sql"); + expect(migrationFiles.at(-1)).toBe("20260717173000_reassert_supabase_admin_default_privileges.sql"); }); it("bootstraps safe default ACLs before fresh local and preview migration replay", () => { @@ -1350,80 +1350,127 @@ describe("Supabase Preview replay guards", () => { expect(ingestionRpcPrivilegesDuplicateMigration).toContain("select 1 where false;"); }); - it("keeps the document-title vocabulary lifecycle aligned in migration and schema", () => { - for (const sql of [schema, patchRagAndCorrectorScalabilityMigration]) { - expect(sql).toContain("create table if not exists public.document_title_words"); - expect(sql).toContain("word text not null"); - expect(sql).toContain("document_id uuid not null references public.documents(id) on delete cascade"); - expect(sql).toContain("primary key (word, document_id)"); - expect(sql).toContain("insert into public.document_title_words (word, document_id)"); - expect(sql).toContain("drop trigger if exists documents_sync_title_words on public.documents"); - expect(sql).toContain("create trigger documents_sync_title_words"); + it("cleans registry projections with text-and-kind matching through the partial expression index", () => { + expect(registryProjectionCleanupMigration).toContain("set lock_timeout = '5s';"); + expect(registryProjectionCleanupMigration).toContain("set statement_timeout = '60s';"); + + for (const sql of [registryProjectionCleanupMigration, schema]) { + const functionStart = sql.indexOf("create or replace function public.cleanup_registry_corpus_document()"); + const functionEnd = sql.indexOf("$$;", functionStart); + const cleanupFunction = sql.slice(functionStart, functionEnd); + + expect(functionStart).toBeGreaterThanOrEqual(0); + expect(cleanupFunction).toContain("metadata->>'registry_record_id' = old.id::text"); + expect(cleanupFunction).not.toContain("metadata->>'registry_record_id')::uuid"); + expect(cleanupFunction).toContain("metadata->>'registry_record_kind' = case tg_table_name"); + expect(cleanupFunction).toContain("when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind'"); + expect(cleanupFunction).toContain("when 'medication_records' then 'medication'"); + expect(cleanupFunction).toContain("when 'differential_records' then 'differential'"); + expect(sql).toContain("create index if not exists documents_registry_projection_lookup_idx"); + expect(sql).toMatch(/\(\s*\(metadata->>'registry_record_kind'\),\s*\(metadata->>'registry_record_id'\)\s*\)/); + expect(sql).toContain("where metadata->>'source_kind' = 'registry_record'"); + expect(sql).toContain( + "revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated, service_role;", + ); } }); - it("hardens registry cleanup without UUID casts or cross-registry collisions", () => { - for (const sql of [schema, hardenRagScalabilityPatchMigration]) { - const cleanup = finalSqlSegment( - sql, - "create or replace function public.cleanup_registry_corpus_document()", - "revoke execute on function public.cleanup_registry_corpus_document()", + it("indexes only public document-title words and probes capped trigram candidates", () => { + for (const sql of [publicTitleCorrectorMigration, schema]) { + const correctorStart = sql.lastIndexOf("create or replace function public.correct_clinical_query_terms("); + const correctorEnd = sql.indexOf( + "revoke execute on function public.correct_clinical_query_terms(text, real)", + correctorStart, ); - expect(cleanup).toContain("metadata->>'registry_record_id' = old.id::text"); - expect(cleanup).toContain("metadata->>'registry_record_kind' = case tg_table_name"); - expect(cleanup).toContain("when 'clinical_registry_records' then to_jsonb(old)->>'kind'"); - expect(cleanup).toContain("when 'medication_records' then 'medication'"); - expect(cleanup).toContain("when 'differential_records' then 'differential'"); - expect(cleanup).not.toContain("registry_record_id')::uuid"); + const corrector = sql.slice(correctorStart, correctorEnd); + + expect(sql).toContain("create table if not exists public.document_title_words"); + expect(sql).toContain("create index if not exists document_title_words_word_trgm_idx"); + expect(sql).toContain("create index if not exists document_title_words_document_id_idx"); + expect(sql).toContain("where d.owner_id is null and d.status = 'indexed'"); + expect(sql).toContain("new.owner_id is null and new.status = 'indexed'"); + expect(sql).toContain("delete from public.document_title_words where document_id = old.id"); + expect(sql).toContain("alter table public.document_title_words enable row level security"); + expect(sql).toContain("revoke all on table public.document_title_words from public, anon, authenticated"); expect(sql).toContain( - "revoke execute on function public.cleanup_registry_corpus_document() from public, anon, authenticated", + "grant select, insert, update, delete on table public.document_title_words to service_role", ); expect(sql).toContain( - "revoke execute on function public.sync_document_title_words() from public, anon, authenticated", + "revoke execute on function public.sync_document_title_words() from public, anon, authenticated, service_role;", ); + expect(sql.indexOf("create trigger documents_sync_title_words")).toBeLessThan( + sql.indexOf("select distinct lower(title_word), d.id"), + ); + expect(sql).toContain("create index if not exists rag_aliases_canonical_trgm_idx"); + expect(correctorStart).toBeGreaterThanOrEqual(0); + expect(correctorEnd).toBeGreaterThan(correctorStart); + expect(corrector).toContain("and lower(alias) % tok"); + expect(corrector).toContain("and lower(canonical) % tok"); + expect((corrector.match(/from public\.rag_aliases where enabled and owner_id is null/g) ?? []).length).toBe(2); + expect(corrector).toContain("and word % tok"); + expect((corrector.match(/limit 32/g) ?? []).length).toBeGreaterThanOrEqual(3); + // An exact alias has similarity 1, but the emitted correction must be its + // canonical term rather than the alias itself. Keep the match score tied + // to the indexed alias expression so typo ranking remains index-friendly. + expect(corrector).toContain("select lower(canonical) as term, similarity(lower(alias), tok) as match_sim"); + expect(corrector).not.toContain("select lower(alias) as term"); + expect(corrector).toContain("select candidate.term, candidate.match_sim"); + expect(corrector).toContain("order by candidate.match_sim desc, candidate.term"); + expect(corrector).toContain("best_sim >= min_sim"); + expect(corrector).toContain("length(best) >= length(tok)"); } - }); - it("uses bounded indexed probes for clinical query correction", () => { - for (const sql of [schema, hardenRagScalabilityPatchMigration]) { - const corrector = finalSqlSegment( - sql, - "create or replace function public.correct_clinical_query_terms", - "revoke execute on function public.correct_clinical_query_terms", + const broadServiceRoleGrant = schema.lastIndexOf( + "grant execute on all functions in schema public to service_role;", + ); + expect(broadServiceRoleGrant).toBeGreaterThanOrEqual(0); + expect( + schema.lastIndexOf("revoke execute on function public.cleanup_registry_corpus_document() from service_role;"), + ).toBeGreaterThan(broadServiceRoleGrant); + expect( + schema.lastIndexOf("revoke execute on function public.sync_document_title_words() from service_role;"), + ).toBeGreaterThan(broadServiceRoleGrant); + }); + + it("keeps the table-facts trigram index expression identical to the active predicate", () => { + const indexStart = schema.indexOf("create index if not exists document_table_facts_title_row_param_trgm_idx"); + const indexExpressionStart = schema.indexOf("using gin (", indexStart) + "using gin (".length; + const indexExpressionEnd = schema.indexOf(" extensions.gin_trgm_ops", indexExpressionStart); + const predicateSection = schema.indexOf("trgm_matches as ("); + const predicateExpressionStart = schema.indexOf("on lower(", predicateSection) + "on ".length; + const predicateExpressionEnd = schema.indexOf(" % q.normalized", predicateExpressionStart); + + expect(indexStart).toBeGreaterThanOrEqual(0); + expect(predicateSection).toBeGreaterThanOrEqual(0); + expect(schema.slice(predicateExpressionStart, predicateExpressionEnd).replaceAll("f.", "")).toBe( + schema.slice(indexExpressionStart, indexExpressionEnd), + ); + expect(schema).not.toContain("document_table_facts_text_trgm_idx"); + }); + + it("defines one stable-order atomic limiter for summary streaming", () => { + for (const sql of [atomicSummaryRateLimitsMigration, schema]) { + expect(sql).toContain("create or replace function public.consume_summary_rate_limits_atomic("); + expect(sql).toContain("order by rl.bucket for update"); + expect(sql).toContain("order by rl.subject_key, rl.bucket for update"); + expect(sql).toContain("'answer'::text, 1"); + expect(sql).toContain("'document_summarize'::text, 3"); + expect((sql.match(/v_success_limit := v_policy\.limit_value/g) ?? []).length).toBe(2); + expect((sql.match(/v_success_reset_at := v_reset_at/g) ?? []).length).toBe(2); + expect(sql).toContain("on conflict on constraint api_rate_limits_pkey do nothing"); + expect(sql).toContain("on conflict on constraint api_rate_limit_subjects_pkey do nothing"); + expect(sql).not.toMatch(/on conflict \([^)]*bucket[^)]*\) do nothing/); + expect(sql).toContain( + "return query select null::text, false, v_success_limit, v_min_remaining, greatest(1, pg_catalog.ceil(extract(epoch from (v_success_reset_at - v_now)))::integer), v_success_reset_at;", + ); + expect(sql).toMatch( + /revoke execute on function public\.consume_summary_rate_limits_atomic\(\s*uuid, text, integer, integer, integer, integer, integer, integer\s*\)/, + ); + expect(sql).toMatch( + /grant execute on function public\.consume_summary_rate_limits_atomic\(\s*uuid, text, integer, integer, integer, integer, integer, integer\s*\) to service_role/, ); - expect(corrector).toContain("lower(alias) % tok"); - expect(corrector).toContain("lower(canonical) % tok"); - expect(corrector).toContain("word % tok"); - expect(corrector).toContain("limit 32"); - expect(corrector).toContain("set pg_trgm.similarity_threshold = 0.3"); - expect(corrector).toContain("min_sim is null or min_sim < 0.3 or min_sim > 1"); - expect(corrector).not.toContain("array_agg(distinct term)"); - expect(corrector).not.toContain("unnest(vocab)"); - expect(sql).toContain("rag_aliases_canonical_trgm_idx"); } }); - - it("drops the mismatched wide table-facts trigram index and preserves RPC parity", () => { - const indexExpression = - "lower(coalesce(table_title, '') || ' ' || coalesce(row_label, '') || ' ' || coalesce(clinical_parameter, ''))"; - const rpcExpression = - "lower(coalesce(f.table_title, '') || ' ' || coalesce(f.row_label, '') || ' ' || coalesce(f.clinical_parameter, ''))"; - const tableFactsRpc = finalSqlSegment( - schema, - "create or replace function public.match_document_table_facts_text(", - "$function$;", - ); - - expect(documentTableFactsTrgmMigration).toContain("create index if not exists document_table_facts_text_trgm_idx"); - expect(hardenRagScalabilityPatchMigration).toContain( - "drop index if exists public.document_table_facts_text_trgm_idx", - ); - expect(schema).not.toContain("create index if not exists document_table_facts_text_trgm_idx"); - expect(schema).toContain( - `create index if not exists document_table_facts_title_row_param_trgm_idx on public.document_table_facts using gin (${indexExpression} extensions.gin_trgm_ops)`, - ); - expect(tableFactsRpc).toContain(`${rpcExpression} % q.normalized`); - }); }); describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => { From 1f9b67ce2b58c0bb80261c552a98dc4fc22ff512 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 17 Jul 2026 20:42:55 +0000 Subject: [PATCH 4/4] fix(ci): restore Home searchParams typing and services metric imports Keep q/focus/run on the home route props and import service navigator metric helpers so the design-audit UI changes typecheck again. --- src/app/page.tsx | 3 +++ src/components/services/services-navigator-page.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/src/app/page.tsx b/src/app/page.tsx index 6ce30c137..5dc55d31b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,9 @@ import { appModeHomeHref, isAppModeId, isAppModeVisible, type AppModeId } from " type HomeProps = { searchParams?: Promise<{ mode?: string | string[]; + q?: string | string[]; + focus?: string | string[]; + run?: string | string[]; }>; }; diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index dd267c8bd..0d857288d 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -34,6 +34,7 @@ import { appModeHomeHref } from "@/lib/app-modes"; import { recordMatchesCommandScopes } from "@/lib/search-command-surface"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker"; +import { canCompareServices, serviceNavigatorMetrics } from "@/lib/service-navigator-metrics"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { sortResultItems } from "@/lib/result-sort"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches";