From c62d268318a65e12d5d57519aa082ef3f9fd8571 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:32:18 +0800 Subject: [PATCH 1/8] perf: reduce search and document latency Add progressive search, bounded document loading, safe owner caches, and indexed persistence paths while preserving existing JSON and detail contracts. Includes Docker replay evidence and targeted offline verification. --- bundle-budget.json | 7 +- ...r-apply-performance-latency-remediation.md | 25 + scripts/check-bundle-budget.mjs | 92 ++- src/app/api/answer/stream/route.ts | 29 +- src/app/api/differentials/[slug]/route.ts | 66 +- .../presentations/[slug]/route.ts | 40 +- src/app/api/differentials/route.ts | 30 +- src/app/api/documents/[id]/labels/route.ts | 8 +- src/app/api/documents/[id]/route.ts | 271 +------- src/app/api/medications/[slug]/route.ts | 32 +- src/app/api/medications/route.ts | 27 +- src/app/api/registry/records/[slug]/route.ts | 32 +- src/app/api/registry/records/route.ts | 27 +- src/app/api/search/universal/route.ts | 78 ++- src/app/documents/[id]/page.tsx | 36 +- src/components/ClinicalDashboard.tsx | 154 ++++- src/components/DocumentViewer.tsx | 629 ++++++++++-------- .../clinical-dashboard/differentials-home.tsx | 18 +- .../use-universal-search.ts | 42 +- src/components/document-viewer-lazy.tsx | 10 +- src/components/forms/form-detail-page.tsx | 5 +- .../forms/forms-search-results-page.tsx | 3 +- .../services/service-detail-page.tsx | 2 +- .../services/services-navigator-page.tsx | 16 +- src/lib/answer-client-payload.ts | 23 +- src/lib/answer-response.ts | 23 +- src/lib/api-rate-limit.ts | 57 ++ src/lib/clinical-safety.ts | 18 +- src/lib/corpus-grounding.ts | 47 +- src/lib/cross-mode-links.ts | 4 +- src/lib/deep-memory.ts | 66 +- src/lib/differential-search-composition.ts | 126 ++++ src/lib/differentials.ts | 125 +--- src/lib/document-detail-contract.ts | 99 +++ src/lib/document-detail.ts | 528 +++++++++++++++ src/lib/document-enrichment.ts | 38 +- src/lib/fixture-response-cache.ts | 45 ++ src/lib/form-catalog.ts | 62 +- src/lib/form-ranker.ts | 171 +++++ src/lib/forms.ts | 116 +--- src/lib/http.ts | 2 +- src/lib/medication-seed.ts | 41 +- src/lib/owner-catalogue-cache.ts | 204 ++++++ src/lib/rag-cache.ts | 50 +- src/lib/rag-candidate-sources.ts | 101 ++- src/lib/rag-retrieval-variants.ts | 14 + src/lib/rag.ts | 107 ++- src/lib/registry-seed.ts | 31 +- src/lib/search-command-surface.ts | 3 +- src/lib/service-ranker.ts | 165 +++++ src/lib/services.ts | 166 +---- src/lib/supabase/auth.ts | 5 +- src/lib/supabase/database.types.ts | 20 + src/lib/types.ts | 15 + src/lib/universal-search-stream.ts | 84 +++ src/lib/universal-search.ts | 180 ++++- src/proxy.ts | 4 +- supabase/drift-manifest.json | 130 +++- ...0717130000_registry_projection_cleanup.sql | 51 ++ .../20260717131000_public_title_corrector.sql | 149 +++++ ...60717132000_atomic_summary_rate_limits.sql | 169 +++++ supabase/schema.sql | 339 ++++++++++ tests/answer-client-payload.test.ts | 47 +- tests/api-rate-limit-fallback.test.ts | 50 ++ tests/api-validation-contract.test.ts | 5 + tests/bundle-budget.test.ts | 55 +- tests/client-performance-boundaries.test.ts | 46 ++ tests/differentials-route.test.ts | 14 + tests/document-detail-performance.test.ts | 111 ++++ .../document-enrichment-visual-counts.test.ts | 97 +++ tests/fixture-response-cache.test.ts | 33 + tests/medications-route.test.ts | 14 + tests/owner-catalogue-cache.test.ts | 195 ++++++ tests/private-access-routes.test.ts | 98 ++- tests/private-error-cache.test.ts | 26 + tests/private-rag-access.test.ts | 3 +- tests/proxy-session-refresh.test.ts | 20 +- tests/rag-abort-signal.test.ts | 17 + tests/rag-cache-invalidation.test.ts | 10 + tests/rag-variant-early-exit.test.ts | 8 +- tests/registry-records-route.test.ts | 15 + tests/supabase-schema.test.ts | 105 +++ tests/universal-search-stream.test.ts | 103 +++ tests/universal-search.test.ts | 201 +++++- .../use-universal-search-stream.dom.test.tsx | 191 ++++++ 85 files changed, 5309 insertions(+), 1412 deletions(-) create mode 100644 docs/operator-apply-performance-latency-remediation.md create mode 100644 src/lib/differential-search-composition.ts create mode 100644 src/lib/document-detail-contract.ts create mode 100644 src/lib/document-detail.ts create mode 100644 src/lib/fixture-response-cache.ts create mode 100644 src/lib/form-ranker.ts create mode 100644 src/lib/owner-catalogue-cache.ts create mode 100644 src/lib/service-ranker.ts create mode 100644 src/lib/universal-search-stream.ts create mode 100644 supabase/migrations/20260717130000_registry_projection_cleanup.sql create mode 100644 supabase/migrations/20260717131000_public_title_corrector.sql create mode 100644 supabase/migrations/20260717132000_atomic_summary_rate_limits.sql create mode 100644 tests/client-performance-boundaries.test.ts create mode 100644 tests/document-detail-performance.test.ts create mode 100644 tests/document-enrichment-visual-counts.test.ts create mode 100644 tests/fixture-response-cache.test.ts create mode 100644 tests/owner-catalogue-cache.test.ts create mode 100644 tests/private-error-cache.test.ts create mode 100644 tests/universal-search-stream.test.ts create mode 100644 tests/use-universal-search-stream.dom.test.tsx diff --git a/bundle-budget.json b/bundle-budget.json index 84feae937..9ebae9854 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,6 +1,7 @@ { - "$comment": "Client JS bundle-size budget. Warn-only until a baseline is captured: after a known-good `npm run build`, run `npm run check:bundle-budget -- --update` to set totalGzipBytes, then flip enforce to true so CI fails on >tolerancePct growth. See scripts/check-bundle-budget.mjs.", - "enforce": false, + "$comment": "Client JS bundle-size budget captured from a known-good production build. CI fails when total gzip size grows beyond tolerancePct; refresh intentionally with `npm run check:bundle-budget -- --update`.", + "enforce": true, "tolerancePct": 10, - "totalGzipBytes": null + "totalGzipBytes": 1363382, + "updatedAt": "2026-07-17T12:11:55.267Z" } diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md new file mode 100644 index 000000000..d39678c72 --- /dev/null +++ b/docs/operator-apply-performance-latency-remediation.md @@ -0,0 +1,25 @@ +# Operator apply — performance and latency remediation + +This worktree does not apply migrations. Production rollout remains a separate, +explicitly authorized operation after local replay, review, and backups. + +## Registry projection index on a busy database + +`20260717130000_registry_projection_cleanup.sql` creates +`documents_registry_projection_lookup_idx` transactionally so clean local +replay remains deterministic. On a busy production database, pre-create the +exact index outside a transaction: + +```sql +create index concurrently if not exists documents_registry_projection_lookup_idx + on public.documents ( + (metadata->>'registry_record_kind'), + (metadata->>'registry_record_id') + ) + where metadata->>'source_kind' = 'registry_record'; +``` + +After the index is valid and ready, the migration's `create index if not +exists` is a no-op. Do not mark the migration applied merely because the index +exists: the cleanup function, hardened privileges, and three lifecycle triggers +must still be installed through the normal authorized migration rollout. diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index c1a17349c..47e6a7113 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -25,6 +25,30 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); const CHUNKS_DIR = path.join(root, ".next", "static", "chunks"); const BUDGET_PATH = path.join(root, "bundle-budget.json"); +const APP_BUILD_MANIFEST_PATH = path.join(root, ".next", "app-build-manifest.json"); +const BUILD_MANIFEST_PATH = path.join(root, ".next", "build-manifest.json"); +const ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH = path.join( + root, + ".next", + "server", + "app", + "page_client-reference-manifest.js", +); + +const fixtureSnapshotMarkerGroups = [ + { + name: "services snapshot", + markers: ["deep_research_citation_tokens", "canonical_name_key", "source_table_lines"], + }, + { + name: "forms fixture catalogue", + markers: ["transport-crisis-form", "extension-transport-order", "detention-examination-movement"], + }, + { + name: "differentials snapshot", + markers: ["redFlagFlows", "searchAliases", "exportedAt"], + }, +]; function walkJsFiles(dir) { const out = []; @@ -74,6 +98,45 @@ export function compareToBudget(current, budget) { }; } +/** Resolve the JavaScript chunks required by the root App Router dashboard. */ +export function initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest) { + const pages = appBuildManifest?.pages ?? {}; + const pageClientChunks = Object.values(pageClientReferenceManifest?.clientModules ?? {}).flatMap((module) => + Array.isArray(module?.chunks) ? module.chunks : [], + ); + const names = new Set([ + ...(appBuildManifest?.rootMainFiles ?? []), + ...(pages["/layout"] ?? []), + ...(pages["/page"] ?? []), + ...pageClientChunks, + ]); + return [...names] + .filter((name) => typeof name === "string" && name.endsWith(".js")) + .map((name) => name.replace(/^\/?static\/chunks\//, "")); +} + +function loadRootPageClientReferenceManifest() { + if (!existsSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH)) return null; + const source = readFileSync(ROOT_PAGE_CLIENT_REFERENCE_MANIFEST_PATH, "utf8"); + const marker = 'globalThis.__RSC_MANIFEST["/page"]='; + const start = source.indexOf(marker); + if (start < 0) return null; + const jsonStart = start + marker.length; + const jsonEnd = source.lastIndexOf(";"); + if (jsonEnd <= jsonStart) return null; + return JSON.parse(source.slice(jsonStart, jsonEnd)); +} + +/** Identify large fixture payloads from stable groups of serialized keys/slugs. + * Requiring every marker in a group avoids failing on ordinary UI copy that + * happens to mention one fixture term. */ +export function findFixtureSnapshotsInChunks(files) { + const content = files.map(({ buffer }) => buffer.toString("utf8")).join("\n"); + return fixtureSnapshotMarkerGroups + .filter((group) => group.markers.every((marker) => content.includes(marker))) + .map((group) => group.name); +} + function kb(bytes) { return `${(bytes / 1024).toFixed(1)} KiB`; } @@ -101,6 +164,30 @@ function main() { name: path.relative(CHUNKS_DIR, full), buffer: readFileSync(full), })); + const manifestPath = existsSync(APP_BUILD_MANIFEST_PATH) + ? APP_BUILD_MANIFEST_PATH + : existsSync(BUILD_MANIFEST_PATH) + ? BUILD_MANIFEST_PATH + : null; + if (!manifestPath) { + console.error("[bundle-budget] FAIL — no build manifest is available; cannot verify initial dashboard chunks."); + process.exit(1); + } + const appBuildManifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const pageClientReferenceManifest = loadRootPageClientReferenceManifest(); + const initialChunkNames = new Set(initialDashboardChunkNames(appBuildManifest, pageClientReferenceManifest)); + const initialDashboardChunks = files.filter((file) => initialChunkNames.has(file.name.replace(/\\/g, "/"))); + if (initialDashboardChunks.length === 0) { + console.error("[bundle-budget] FAIL — no root dashboard JavaScript chunks were resolved from the build manifest."); + process.exit(1); + } + const fixtureViolations = findFixtureSnapshotsInChunks(initialDashboardChunks); + if (fixtureViolations.length > 0) { + console.error( + `[bundle-budget] FAIL — initial dashboard chunks contain fixture payloads: ${fixtureViolations.join(", ")}.`, + ); + process.exit(1); + } const current = measureChunks(files); const budget = loadBudget(); @@ -113,7 +200,7 @@ function main() { const verdict = compareToBudget(current, budget); if (asJson) { - console.log(JSON.stringify({ current, verdict }, null, 2)); + console.log(JSON.stringify({ current, verdict, initialDashboardChunks: initialDashboardChunks.length }, null, 2)); } else { console.log( `[bundle-budget] client chunks: ${current.files} files, ${kb(current.totalGzipBytes)} gzip (${kb(current.totalRawBytes)} raw).`, @@ -122,6 +209,9 @@ function main() { console.log(`[bundle-budget] baseline ${kb(verdict.baseline)} gzip; ${verdict.reason}.`); console.log("[bundle-budget] largest chunks (gzip):"); for (const c of current.largest) console.log(` ${kb(c.gzipBytes).padStart(12)} ${c.name}`); + console.log( + `[bundle-budget] initial dashboard fixture assertion passed (${initialDashboardChunks.length} chunks).`, + ); } if (verdict.status === "fail") { diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index eff39611a..843ee7153 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -5,6 +5,7 @@ import { isDemoMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, + consumeSummaryRateLimits, consumeSubjectApiRateLimit, rateLimitJsonResponse, type ApiRateLimitResult, @@ -272,24 +273,24 @@ export async function POST(request: Request) { const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); - const rateLimit = await consumeSubjectApiRateLimit({ - supabase, - subject: access.rateLimitSubject, - bucket: "answer", - allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), - }); - if (rateLimit.limited) return rateLimitStream(rateLimit); - if (body.summaryMode) { - // Streamed full-document summaries use the same paid provider path as the - // legacy summary endpoint. Preserve the general answer ceiling, then also - // enforce the stricter summary quota before the SSE stream can start. - const summaryRateLimit = await consumeSubjectApiRateLimit({ + const decision = await consumeSummaryRateLimits({ + supabase, + subject: access.rateLimitSubject, + }); + if (decision.rateLimit.limited) { + return decision.bucket === "document_summarize" + ? documentSummaryRateLimitStream(decision.rateLimit) + : rateLimitStream(decision.rateLimit); + } + } else { + const rateLimit = await consumeSubjectApiRateLimit({ supabase, subject: access.rateLimitSubject, - bucket: "document_summarize", + bucket: "answer", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); - if (summaryRateLimit.limited) return documentSummaryRateLimitStream(summaryRateLimit); + if (rateLimit.limited) return rateLimitStream(rateLimit); } return streamAnswer(body, resolveRetrievalAccessScope(access.ownerId), request.signal); diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index e61e7fcf1..25fd4e50f 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -17,6 +17,7 @@ import { import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -30,10 +31,13 @@ const differentialDetailQuerySchema = z.object({ kind: z.enum(["presentation", "diagnosis"]).optional().default("diagnosis"), }); -function differentialResponse(payload: Record, init?: { status?: number }) { +function differentialResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -53,20 +57,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (kind === "presentation") { const workflow = getPresentationWorkflow(normalizedSlug); if (!workflow) return notFoundResponse(normalizedSlug); - return differentialResponse({ - workflow, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - demoMode: true, - }); + return differentialResponse( + { + workflow, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + demoMode: true, + }, + { request, fixture: true }, + ); } const record = getDifferentialRecord(normalizedSlug); if (!record) return notFoundResponse(normalizedSlug); - return differentialResponse({ - record, - detailContext: getDifferentialDetailContext(record), - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - demoMode: true, - }); + return differentialResponse( + { + record, + detailContext: getDifferentialDetailContext(record), + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -91,20 +101,26 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (kind === "presentation") { const workflow = getPresentationWorkflow(normalizedSlug); if (!workflow) return notFoundResponse(normalizedSlug); - return differentialResponse({ - workflow, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - publicAccess: true, - }); + return differentialResponse( + { + workflow, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + publicAccess: true, + }, + { request, fixture: true }, + ); } const record = getDifferentialRecord(normalizedSlug); if (!record) return notFoundResponse(normalizedSlug); - return differentialResponse({ - record, - detailContext: getDifferentialDetailContext(record), - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - publicAccess: true, - }); + return differentialResponse( + { + record, + detailContext: getDifferentialDetailContext(record), + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchRecord = async () => { diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index afc883800..a4ccc4744 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -16,6 +16,7 @@ import { import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -24,10 +25,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; -function differentialResponse(payload: Record, init?: { status?: number }) { +function differentialResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -50,12 +54,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return []; return [{ ...candidate, record }]; }); - return differentialResponse({ - workflow, - candidates, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - demoMode: true, - }); + return differentialResponse( + { + workflow, + candidates, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -84,12 +91,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!record) return []; return [{ ...candidate, record }]; }); - return differentialResponse({ - workflow, - candidates, - governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, - publicAccess: true, - }); + return differentialResponse( + { + workflow, + candidates, + governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchPresentation = async () => { diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index 256d6a187..ae4559303 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -22,6 +22,7 @@ import { type DifferentialRecordMatch, } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -43,8 +44,11 @@ const differentialListQuerySchema = z.object({ limit: queryInteger({ fallback: 100, min: 1, max: 200 }), }); -function differentialResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function differentialResponse( + payload: Record, + options: { request?: Request; fixture?: boolean } = {}, +) { + return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } function recordMatchesPayload(matches: DifferentialRecordMatch[]) { @@ -82,10 +86,13 @@ export async function GET(request: Request) { const { kind, q, limit } = parseRequestQuery(request, differentialListQuerySchema, "Invalid differential query."); if (isDemoMode() || isLocalNoAuthMode()) { - return differentialResponse({ - ...publicDifferentialPayload(kind, q, limit), - demoMode: true, - }); + return differentialResponse( + { + ...publicDifferentialPayload(kind, q, limit), + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit: publicAccessContext skips the @@ -105,10 +112,13 @@ export async function GET(request: Request) { } if (!access.ownerId) { - return differentialResponse({ - ...publicDifferentialPayload(kind, q, limit), - publicAccess: true, - }); + return differentialResponse( + { + ...publicDifferentialPayload(kind, q, limit), + publicAccess: true, + }, + { request, fixture: true }, + ); } const rows = await fetchOwnerDifferentialRowsWithSeed(supabase, access.ownerId, kind, DIFFERENTIAL_MAX_RECORDS); diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index dde68684b..6cd574841 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -145,7 +145,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: .single(); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ label, labels: await selectLabels(supabase, id) }, { status: 201 }); } catch (error) { if (error instanceof AuthenticationError) { @@ -200,7 +200,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ label }); } @@ -243,7 +243,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ label, labels: await selectLabels(supabase, id) }); } catch (error) { if (error instanceof AuthenticationError) { @@ -288,7 +288,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i .eq("source", "manual"); if (error) throw new Error(error.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); return NextResponse.json({ deleted: true, labelId: parsed.labelId, labels: await selectLabels(supabase, id) }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 5246c653c..9fbc912dd 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -2,19 +2,21 @@ import { NextResponse } from "next/server"; import type { Json } from "@/lib/supabase/database.types"; import { z } from "zod"; import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; -import { getDemoDocumentPayload } from "@/lib/demo-data"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { buildStorageCleanupJobUpdate } from "@/lib/ingestion"; import { invalidateRagCachesForDocumentMutation } from "@/lib/rag"; -import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; -import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; +import { + DocumentDetailRateLimitError, + documentDetailQuerySchema, + loadAuthorizedDocumentDetail, +} from "@/lib/document-detail"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; -import { optionalQueryString, parseRequestQuery, queryInteger } from "@/lib/validation/query"; +import { parseRequestQuery } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -28,27 +30,6 @@ const documentRouteParamsSchema = z.object({ }); const cleanupPageSize = 1000; -const defaultPageWindow = 9; -const maxPageWindow = 40; -const defaultChunkWindow = 16; -const maxChunkWindow = 80; -const selectedChunkNeighborCount = 3; - -const documentDetailQuerySchema = z.object({ - chunk: optionalQueryString({ maxLength: 80 }), - page: queryInteger({ fallback: 1, min: 1, max: 1_000_000 }), - pageLimit: queryInteger({ fallback: defaultPageWindow, min: 1, max: maxPageWindow }), - chunkLimit: queryInteger({ fallback: defaultChunkWindow, min: 1, max: maxChunkWindow }), - chunkOffset: queryInteger({ fallback: 0, min: 0, max: 1_000_000 }), -}); - -function pageWindowAround(pageNumber: number, limit: number, maxPage?: number | null) { - const half = Math.floor(limit / 2); - const max = Math.max(1, maxPage ?? Number.MAX_SAFE_INTEGER); - const from = Math.max(1, Math.min(pageNumber - half, Math.max(1, max - limit + 1))); - const to = Math.min(max, from + limit - 1); - return { from, to }; -} function safeMetadata(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; @@ -80,59 +61,6 @@ async function selectDocumentRowsInPages(args: { return rows; } -function metadataText(metadata: Record, key: string) { - const value = metadata[key]; - return typeof value === "string" && value.trim() ? value.trim() : null; -} - -function compactTableText(value: string | null, limit = 500) { - if (!value) return null; - const compact = value.replace(/\s+/g, " ").trim(); - if (!compact) return null; - return compact.length > limit ? `${compact.slice(0, limit - 3).trim()}...` : compact; -} - -function metadataStringArrayRows(metadata: Record, key: string) { - const value = metadata[key]; - if (!Array.isArray(value)) return null; - const rows = value - .filter((row): row is unknown[] => Array.isArray(row)) - .map((row) => row.map((cell) => String(cell ?? "").trim())); - return rows.length ? rows : null; -} - -function metadataStringArray(metadata: Record, key: string) { - const value = metadata[key]; - if (!Array.isArray(value)) return null; - const items = value.map((item) => String(item ?? "").trim()).filter(Boolean); - return items.length ? items : null; -} - -function withImageTableMetadata(image: T) { - const metadata = safeMetadata(image.metadata); - const rawTableText = metadataText(metadata, "table_text"); - const tableText = rawTableText ?? metadataText(metadata, "table_text_snippet"); - const publicImage = { ...image }; - delete publicImage.metadata; - return { - ...publicImage, - tableLabel: metadataText(metadata, "table_label"), - tableTitle: metadataText(metadata, "table_title"), - tableRole: metadataText(metadata, "table_role"), - tableTextSnippet: compactTableText(tableText), - clinicalUseClass: metadataText(metadata, "clinical_use_class"), - clinicalUseReason: metadataText(metadata, "clinical_use_reason"), - accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText, - tableRows: metadataStringArrayRows(metadata, "table_rows"), - tableColumns: metadataStringArray(metadata, "table_columns"), - }; -} - -function committedRows(document: { metadata?: unknown }, rows: T[]) { - const committedGeneration = committedIndexGeneration(document.metadata); - return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration })); -} - function storageWarningsFrom(error: unknown, label: string) { const message = error && typeof error === "object" && "message" in error ? String(error.message) : "Storage cleanup failed."; @@ -274,185 +202,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: try { const { id: rawId } = await params; const detailQuery = parseRequestQuery(request, documentDetailQuerySchema, "Invalid document detail query."); - if (isDemoMode()) { - const payload = getDemoDocumentPayload(rawId, detailQuery.chunk ?? null); - if (!payload) { - return NextResponse.json({ error: "Demo document not found." }, { status: 404 }); - } - return NextResponse.json({ ...payload, demoMode: true }); - } - - const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); - const supabase = createAdminClient(); - const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); - if (rateLimit.limited) { - return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); - } - const { data: document, error } = await withOwnerReadScope( - supabase.from("documents").select("*").eq("id", id), - access.ownerId, - ).maybeSingle(); - - if (error) throw new Error(error.message); - if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); - - // withOwnerReadScope also returns PUBLIC (owner_id IS NULL) documents to an authenticated - // caller. The owner-only projection (raw metadata, storage_path/content_hash, index health, - // image storage paths) must be gated on OWNERSHIP, not merely on being authenticated, so an - // authed non-owner viewing a shared public document gets the same redacted view as an - // anonymous caller (S1/D1). - const isOwner = callerOwnsDocumentRow(document, access.ownerId); - - const chunkId = detailQuery.chunk ?? null; - const requestedPage = Math.min(detailQuery.page, Math.max(1, document.page_count ?? 1)); - const pageLimit = detailQuery.pageLimit; - const chunkLimit = detailQuery.chunkLimit; - const chunkOffset = detailQuery.chunkOffset; - - let selectedChunk: { - id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids: string[]; - } | null = null; - - if (chunkId) { - const { data, error: selectedChunkError } = await supabase - .from("document_chunks") - .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata") - .eq("document_id", id) - .eq("id", chunkId) - .maybeSingle(); - - if (selectedChunkError) throw new Error(selectedChunkError.message); - selectedChunk = data && committedRows(document, [data]).length > 0 ? data : null; - } - - const effectivePage = selectedChunk?.page_number ?? requestedPage; - const pageWindow = pageWindowAround(effectivePage, pageLimit, document.page_count); - const { data: pages, error: pagesError } = await supabase - .from("document_pages") - .select("id,page_number,text,ocr_used,metadata") - .eq("document_id", id) - .gte("page_number", pageWindow.from) - .lte("page_number", pageWindow.to) - .order("page_number", { ascending: true }); - - if (pagesError) throw new Error(pagesError.message); - - const { data: images, error: imagesError } = await supabase - .from("document_images") - .select( - "id,page_number,storage_path,caption,bbox,mime_type,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata", - ) - .eq("document_id", id) - .neq("image_type", "logo_decorative") - .or("searchable.eq.true,source_kind.eq.table_crop") - .order("page_number", { ascending: true }); - - if (imagesError) throw new Error(imagesError.message); - - const chunkQuery = supabase - .from("document_chunks") - .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata") - .eq("document_id", id) - .order("chunk_index", { ascending: true }); - - const chunkRangeStart = selectedChunk - ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount) - : chunkOffset; - const chunkRangeEnd = selectedChunk - ? selectedChunk.chunk_index + selectedChunkNeighborCount - : chunkOffset + chunkLimit - 1; - - const { data: chunks, error: chunksError } = selectedChunk - ? await chunkQuery.gte("chunk_index", chunkRangeStart).lte("chunk_index", chunkRangeEnd) - : await chunkQuery.range(chunkRangeStart, chunkRangeEnd); - - if (chunksError) throw new Error(chunksError.message); - - const [labelsResult, summaryResult, tableFactsResult] = await Promise.all([ - supabase.from("document_labels").select("*").eq("document_id", id).order("confidence", { ascending: false }), - supabase.from("document_summaries").select("*").eq("document_id", id).maybeSingle(), - supabase - .from("document_table_facts") - .select("*") - .eq("document_id", id) - .order("page_number", { ascending: true }) - .limit(200), - ]); - - if (labelsResult.error) throw new Error(labelsResult.error.message); - if (summaryResult.error) throw new Error(summaryResult.error.message); - if (tableFactsResult.error) throw new Error(tableFactsResult.error.message); - - const omitPublicInternalFields = (row: Record) => { - const internalKeys = new Set([ - "owner_id", - "storage_path", - "content_hash", - "source_path", - "import_batch_id", - "error_message", - "metadata", - // Summary provenance: only present on document_summaries rows (fetched with select("*")). - // A non-owner viewing a public document's summary must not see the owner's chunk/image - // source IDs or the generation model, matching the list route's PUBLIC_SUMMARY projection. - "source_chunk_ids", - "source_image_ids", - "model", - ]); - return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key))); - }; - const publicRows = >(rows: T[]) => - isOwner ? rows : rows.map(omitPublicInternalFields); - const responseDocument = isOwner ? document : omitPublicInternalFields(document as Record); - - return NextResponse.json({ - document: { - ...responseDocument, - labels: publicRows((labelsResult.data ?? []) as Record[]), - summary: - isOwner || !summaryResult.data - ? (summaryResult.data ?? null) - : omitPublicInternalFields(summaryResult.data as Record), - }, - pages: publicRows((pages ?? []) as Record[]), - images: publicRows( - committedRows(document, images ?? []).map(withImageTableMetadata) as Record[], - ), - tableFacts: publicRows(committedRows(document, tableFactsResult.data ?? []) as Record[]), - chunks: publicRows(committedRows(document, chunks ?? []) as Record[]), - pageWindow: { - from: pageWindow.from, - to: pageWindow.to, - limit: pageLimit, - total: document.page_count ?? null, - hasBefore: pageWindow.from > 1, - hasAfter: Boolean(document.page_count && pageWindow.to < document.page_count), - }, - chunkWindow: { - offset: chunkRangeStart, - limit: selectedChunk ? chunkRangeEnd - chunkRangeStart + 1 : chunkLimit, - total: document.chunk_count ?? null, - hasBefore: chunkRangeStart > 0, - hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count), - selectedChunkId: selectedChunk?.id ?? null, - }, - ...(isOwner - ? { - indexHealth: { - extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null, - indexedAt: safeMetadata(document.metadata).indexed_at ?? null, - indexVersion: safeMetadata(document.metadata).rag_indexing_version ?? null, - warnings: safeMetadata(document.metadata).extraction_warnings ?? [], - }, - } - : {}), - }); + const payload = await loadAuthorizedDocumentDetail({ request, rawId, query: detailQuery }); + return NextResponse.json(payload); } catch (error) { + if (error instanceof DocumentDetailRateLimitError) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", error.rateLimit); + } if (error instanceof AuthenticationError) { return unauthorizedResponse(); } @@ -504,7 +259,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .single(); if (updateError) throw new Error(updateError.message); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); await writeAuditLog(supabase, { ownerId: user.id, action: "document_rename", @@ -649,7 +404,7 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i }); if (ledgerWarning) cleanup.storageWarnings.push(ledgerWarning); - invalidateRagCachesForDocumentMutation(user.id); + invalidateRagCachesForDocumentMutation(user.id, { affectsPublicCorpus: false }); await writeAuditLog(supabase, { ownerId: user.id, action: "document_delete", diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 48de02a16..a02dfaebe 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -6,6 +6,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; import { ensureMedicationsSeeded } from "@/lib/medication-seed"; @@ -23,10 +24,13 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; export const runtime = "nodejs"; -function medicationResponse(payload: Record, init?: { status?: number }) { +function medicationResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -55,10 +59,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (isDemoMode() || isLocalNoAuthMode()) { const payload = publicMedicationDetailPayload(normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return medicationResponse({ - ...payload, - demoMode: true, - }); + return medicationResponse( + { + ...payload, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -80,10 +87,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!access.ownerId) { const payload = publicMedicationDetailPayload(normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return medicationResponse({ - ...payload, - publicAccess: true, - }); + return medicationResponse( + { + ...payload, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchRecord = async () => { diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index adb6e9743..7562cf418 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -7,6 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; import { @@ -61,8 +62,8 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { })); } -function medicationResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function medicationResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) { + return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } function matchesPayload(matches: MedicationSearchMatch[]) { @@ -99,10 +100,13 @@ export async function GET(request: Request) { const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); if (isDemoMode() || isLocalNoAuthMode()) { - return medicationResponse({ - ...publicMedicationPayload(q, limit, fields), - demoMode: true, - }); + return medicationResponse( + { + ...publicMedicationPayload(q, limit, fields), + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit: publicAccessContext skips the @@ -122,10 +126,13 @@ export async function GET(request: Request) { } if (!access.ownerId) { - return medicationResponse({ - ...publicMedicationPayload(q, limit, fields), - publicAccess: true, - }); + return medicationResponse( + { + ...publicMedicationPayload(q, limit, fields), + publicAccess: true, + }, + { request, fixture: true }, + ); } const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index e3bb5a6be..44d8f372d 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -7,6 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; @@ -28,10 +29,13 @@ const registryDetailQuerySchema = z.object({ kind: z.enum(["service", "form"]), }); -function registryResponse(payload: Record, init?: { status?: number }) { +function registryResponse( + payload: Record, + init: { status?: number; request?: Request; fixture?: boolean } = {}, +) { return NextResponse.json(payload, { - status: init?.status ?? 200, - headers: { "Cache-Control": "private, no-store" }, + status: init.status ?? 200, + headers: fixtureResponseHeaders(init.request, init), }); } @@ -59,10 +63,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (isDemoMode() || isLocalNoAuthMode()) { const payload = publicRegistryDetailPayload(kind, normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return registryResponse({ - ...payload, - demoMode: true, - }); + return registryResponse( + { + ...payload, + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit before we serve the seed detail: @@ -84,10 +91,13 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if (!access.ownerId) { const payload = publicRegistryDetailPayload(kind, normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); - return registryResponse({ - ...payload, - publicAccess: true, - }); + return registryResponse( + { + ...payload, + publicAccess: true, + }, + { request, fixture: true }, + ); } const fetchRecord = async () => { diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 309981079..8326c168d 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -7,6 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; +import { fixtureResponseHeaders } from "@/lib/fixture-response-cache"; import { jsonError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; @@ -45,8 +46,8 @@ function rankRecords(kind: RegistryRecordKind, records: ServiceRecord[], query: return kind === "form" ? rankFormRecords(records, query, limit) : rankServiceRecords(records, query, limit); } -function registryResponse(payload: Record) { - return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); +function registryResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) { + return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } function matchesPayload(matches: ServiceSearchMatch[]) { @@ -74,10 +75,13 @@ export async function GET(request: Request) { const { kind, q, limit } = parseRequestQuery(request, registryListQuerySchema, "Invalid registry query."); if (isDemoMode() || isLocalNoAuthMode()) { - return registryResponse({ - ...publicRegistryPayload(kind, q, limit), - demoMode: true, - }); + return registryResponse( + { + ...publicRegistryPayload(kind, q, limit), + demoMode: true, + }, + { request, fixture: true }, + ); } // Anonymous callers still resolve access + rate limit: publicAccessContext skips the @@ -97,10 +101,13 @@ export async function GET(request: Request) { } if (!access.ownerId) { - return registryResponse({ - ...publicRegistryPayload(kind, q, limit), - publicAccess: true, - }); + return registryResponse( + { + ...publicRegistryPayload(kind, q, limit), + publicAccess: true, + }, + { request, fixture: true }, + ); } const rows = await fetchOwnerRegistryRows(supabase, access.ownerId, kind, REGISTRY_MAX_RECORDS); diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index ef4b0541b..32d4d1702 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -15,6 +15,7 @@ import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { runUniversalSearch, universalSearchDomains, + type RunUniversalSearchArgs, type UniversalSearchDomain, type UniversalSearchResponse, } from "@/lib/universal-search"; @@ -35,6 +36,7 @@ const universalSearchQuerySchema = z.object({ q: z.string().trim().min(2).max(200), limit: queryInteger({ fallback: 5, min: 1, max: 10 }), mode: z.enum(appModeIds).optional(), + stream: z.enum(["ndjson"]).optional(), domains: z .string() .trim() @@ -64,22 +66,84 @@ function universalResponse( return NextResponse.json(payload, { headers }); } +function universalStreamResponse( + request: Request, + searchArgs: RunUniversalSearchArgs, + decoration: { demoMode?: true; publicAccess?: true } = {}, +) { + const encoder = new TextEncoder(); + const searchController = new AbortController(); + const abortFromRequest = () => searchController.abort(request.signal.reason); + if (request.signal.aborted) abortFromRequest(); + else request.signal.addEventListener("abort", abortFromRequest, { once: true }); + + const body = new ReadableStream({ + start(controller) { + const enqueue = (event: Record) => { + if (!searchController.signal.aborted) { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + } + }; + + void runUniversalSearch({ + ...searchArgs, + signal: searchController.signal, + onGroup: (group) => enqueue({ type: "group", query: searchArgs.query, group }), + }) + .then((response) => { + if (searchController.signal.aborted) return; + enqueue({ type: "complete", response: { ...response, ...decoration } }); + controller.close(); + }) + .catch((error: unknown) => { + if (searchController.signal.aborted) { + try { + controller.close(); + } catch { + // The consumer may already have cancelled the stream. + } + return; + } + controller.error(error); + }) + .finally(() => request.signal.removeEventListener("abort", abortFromRequest)); + }, + cancel(reason) { + searchController.abort( + reason instanceof Error ? reason : new DOMException("The search stream was cancelled.", "AbortError"), + ); + request.signal.removeEventListener("abort", abortFromRequest); + }, + }); + + return new Response(body, { + headers: { + "Cache-Control": "private, no-store", + "Content-Type": "application/x-ndjson; charset=utf-8", + "X-Accel-Buffering": "no", + "X-Content-Type-Options": "nosniff", + }, + }); +} + export async function GET(request: Request) { try { - const { q, limit, domains, mode } = parseRequestQuery( + const { q, limit, domains, mode, stream } = parseRequestQuery( request, universalSearchQuerySchema, "Invalid universal query.", ); if (isDemoMode() || isLocalNoAuthMode()) { - const payload = await runUniversalSearch({ + const searchArgs: RunUniversalSearchArgs = { query: q, limitPerDomain: limit, domains, contextMode: mode, demo: true, - }); + }; + if (stream === "ndjson") return universalStreamResponse(request, searchArgs, { demoMode: true }); + const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal }); return universalResponse({ ...payload, demoMode: true }); } @@ -99,7 +163,7 @@ export async function GET(request: Request) { // demo:false + supabase always run the live pipeline. An anonymous caller (ownerId // undefined) is scoped to the public corpus via allowGlobalSearch and the real default // catalogues — never the synthetic demo fixtures. - const payload = await runUniversalSearch({ + const searchArgs: RunUniversalSearchArgs = { query: q, limitPerDomain: limit, domains, @@ -107,7 +171,11 @@ export async function GET(request: Request) { supabase, ownerId: access.ownerId, demo: false, - }); + }; + if (stream === "ndjson") { + return universalStreamResponse(request, searchArgs, access.ownerId ? {} : { publicAccess: true }); + } + const payload = await runUniversalSearch({ ...searchArgs, signal: request.signal }); return universalResponse(access.ownerId ? payload : { ...payload, publicAccess: true }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/documents/[id]/page.tsx b/src/app/documents/[id]/page.tsx index 19a7cf04b..10413f356 100644 --- a/src/app/documents/[id]/page.tsx +++ b/src/app/documents/[id]/page.tsx @@ -1,4 +1,11 @@ +import { headers } from "next/headers"; import { DocumentViewerLazy as DocumentViewer } from "@/components/document-viewer-lazy"; +import { + documentDetailQuerySchema, + loadAuthorizedDocumentDetail, + sanitizeDocumentDetailError, +} from "@/lib/document-detail"; +import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; export default async function DocumentPage({ params, @@ -10,5 +17,32 @@ export default async function DocumentPage({ const [{ id }, query] = await Promise.all([params, searchParams]); const parsedPage = Number.parseInt(query.page ?? "", 10); const initialPage = Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1; - return ; + let initialDetail: DocumentDetailPayload | undefined; + let initialError: string | undefined; + + try { + const detailQuery = documentDetailQuerySchema.parse({ + page: initialPage, + chunk: query.chunk, + assetScope: "window", + }); + const requestHeaders = new Headers(await headers()); + const request = new Request(`http://document-detail.local/documents/${encodeURIComponent(id)}`, { + headers: requestHeaders, + }); + initialDetail = await loadAuthorizedDocumentDetail({ request, rawId: id, query: detailQuery }); + } catch (error) { + initialError = sanitizeDocumentDetailError(error); + } + + return ( + + ); } diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 931335a1a..623187b84 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -187,8 +187,8 @@ import { import { persistPrivateSearchScope, restorePrivateSearchScope } from "@/lib/private-search-scope"; import { parseApiErrorResponse } from "@/lib/api-client-error"; import { answerLifecycleReducer, initialAnswerLifecycle } from "@/lib/answer-lifecycle"; -import { rankFormRecords } from "@/lib/forms"; -import { rankServiceRecords } from "@/lib/services"; +import { rankFormRecords } from "@/lib/form-ranker"; +import { rankServiceRecords } from "@/lib/service-ranker"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { @@ -232,6 +232,7 @@ const stagedDashboardExtraction = { type RefreshOptions = { includeSetup?: boolean; includeDashboardData?: boolean; + includeAdministrationData?: boolean; includeDocumentMeta?: boolean; }; type PollHint = { @@ -446,16 +447,23 @@ export function ClinicalDashboard({ const scrollFrameRef = useRef(null); const navSyncLockRef = useRef(null); const autoRunSearchSignatureRef = useRef(null); - const refreshInFlightRef = useRef<{ epoch: number; promise: Promise } | null>(null); + const refreshInFlightRef = useRef<{ + epoch: number; + dataScope: number; + promise: Promise; + } | null>(null); + const dashboardDataLoadedRef = useRef(false); + const administrationDataLoadedRef = useRef(false); const nextWorkStatePollRef = useRef(0); const urlSearchBootstrappedRef = useRef(false); const urlDocumentSearchBootstrappedRef = useRef(false); const lastSyncedSearchParamsRef = useRef(searchParams.toString()); const modeChangeFromUiRef = useRef(false); const [documents, setDocuments] = useState([]); + const documentsRef = useRef(documents); const [documentsPagination, setDocumentsPagination] = useState(null); const indexedDocumentTotal = documentsPagination?.total ?? documents.length; - const [dashboardDataLoading, setDashboardDataLoading] = useState(true); + const [dashboardDataLoading, setDashboardDataLoading] = useState(false); const [loadingMoreDocuments, setLoadingMoreDocuments] = useState(false); const [jobs, setJobs] = useState([]); const [batches, setBatches] = useState([]); @@ -664,6 +672,7 @@ export function ClinicalDashboard({ ); const [indexingActionId, setIndexingActionId] = useState(null); const [indexingActive, setIndexingActive] = useState(false); + const [userStartedIngestion, setUserStartedIngestion] = useState(false); const [nextRefreshDelayMs, setNextRefreshDelayMs] = useState(null); const { theme, toggleTheme } = useTheme(); const auth = useAuthSession(); @@ -743,6 +752,9 @@ export function ClinicalDashboard({ setJobs([]); setBatches([]); setQualityItems([]); + dashboardDataLoadedRef.current = false; + administrationDataLoadedRef.current = false; + setUserStartedIngestion(false); setSelectedDocumentIds([]); setDocumentMatches([]); setSearchScope(null); @@ -987,6 +999,10 @@ export function ClinicalDashboard({ answerThreadOwnerId, ]); + useEffect(() => { + documentsRef.current = documents; + }, [documents]); + useEffect(() => { jobsRef.current = jobs; }, [jobs]); @@ -995,10 +1011,22 @@ export function ClinicalDashboard({ batchesRef.current = batches; }, [batches]); - const refresh = useCallback( + const refresh: (options?: RefreshOptions) => Promise = useCallback( async (options: RefreshOptions = {}) => { - if (refreshInFlightRef.current?.epoch === authEpoch) { - return refreshInFlightRef.current.promise; + const includeDashboardData = options.includeDashboardData ?? true; + const includeAdministrationData = options.includeAdministrationData ?? includeDashboardData; + const requestedDataScope = (includeDashboardData ? 1 : 0) | (includeAdministrationData ? 2 : 0); + while (refreshInFlightRef.current?.epoch === authEpoch) { + const activeRefresh = refreshInFlightRef.current; + const needsFollowUp = (requestedDataScope & ~activeRefresh.dataScope) !== 0; + await activeRefresh.promise; + // A setup-only refresh cannot satisfy a data request that arrived + // while it was in flight. Run one follow-up request; same-scope calls + // stay coalesced on the original promise. + if (!needsFollowUp) return; + // The promise is complete, so release its coalescing slot now. The + // owning call still releases its auth request in its own finally. + if (refreshInFlightRef.current === activeRefresh) refreshInFlightRef.current = null; } const controller = new AbortController(); @@ -1006,12 +1034,11 @@ export function ClinicalDashboard({ const canCommit = () => isAuthEpochCurrent(authRequest.epoch) && !controller.signal.aborted; const promise = (async () => { - const trackDashboardLoading = options.includeDashboardData ?? true; + const trackDashboardLoading = requestedDataScope !== 0; await Promise.resolve(); if (trackDashboardLoading) setDashboardDataLoading(true); const includeSetup = options.includeSetup ?? true; - const includeDashboardData = options.includeDashboardData ?? true; const includeDocumentMeta = options.includeDocumentMeta ?? true; let nextDemoMode = clientDemoMode; let routeIndexingActive = false; @@ -1078,7 +1105,7 @@ export function ClinicalDashboard({ return; } - if (!includeDashboardData) { + if (requestedDataScope === 0) { setIndexingActive(routeIndexingActive); setNextRefreshDelayMs(routePollDelayMs); return; @@ -1091,14 +1118,17 @@ export function ClinicalDashboard({ } const now = Date.now(); - const shouldRefreshWorkState = now >= nextWorkStatePollRef.current; + const shouldRefreshWorkState = + includeAdministrationData && (!administrationDataLoadedRef.current || now >= nextWorkStatePollRef.current); if (shouldRefreshWorkState) nextWorkStatePollRef.current = now + indexingWorkDetailsPollMs; const [documentsResponse, jobsResponse, batchesResponse, qualityResponse] = await Promise.all([ - fetch(`/api/documents?${documentParams.toString()}`, { - headers: protectedHeaders, - signal: controller.signal, - }), + includeDashboardData + ? fetch(`/api/documents?${documentParams.toString()}`, { + headers: protectedHeaders, + signal: controller.signal, + }) + : Promise.resolve(null as Response | null), shouldRefreshWorkState ? fetch("/api/ingestion/jobs", { headers: protectedHeaders, signal: controller.signal }) : Promise.resolve(null as Response | null), @@ -1110,9 +1140,8 @@ export function ClinicalDashboard({ : Promise.resolve(null as Response | null), ]); if (!canCommit()) return; - if ( - documentsResponse.status === 401 || + (documentsResponse !== null && documentsResponse.status === 401) || (jobsResponse !== null && jobsResponse.status === 401) || (batchesResponse !== null && batchesResponse.status === 401) || (qualityResponse !== null && qualityResponse.status === 401) @@ -1128,22 +1157,23 @@ export function ClinicalDashboard({ return; } - let nextDocuments: ClinicalDocument[] = []; + let nextDocuments: ClinicalDocument[] = includeDashboardData ? [] : documentsRef.current; let nextJobs: IngestionJob[] = shouldRefreshWorkState ? [] : jobsRef.current; let nextBatches: ImportBatch[] = shouldRefreshWorkState ? [] : batchesRef.current; - if (documentsResponse.ok) { + if (documentsResponse?.ok) { const payload = (await documentsResponse.json()) as DocumentsPayload; nextDocuments = payload.documents ?? []; setDocuments((current) => includeDocumentMeta ? nextDocuments : mergeDocumentRefresh(current, nextDocuments), ); setDocumentsPagination(payload.pagination ?? null); + dashboardDataLoadedRef.current = true; routeIndexingActive ||= Boolean(payload.indexing?.active); routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.indexing?.pollAfterMs); if (payload.demoMode) setDemoMode(true); if (payload.setupRequired) setSetupWarning(payload.error ?? null); - } else { + } else if (includeDashboardData) { setApiUnavailable(true); } @@ -1178,17 +1208,21 @@ export function ClinicalDashboard({ setApiUnavailable(true); } + if (jobsResponse?.ok && batchesResponse?.ok && qualityResponse?.ok) { + administrationDataLoadedRef.current = true; + } + const activeWork = hasActiveIndexingWork(nextDocuments, nextJobs, nextBatches, routeIndexingActive); setIndexingActive(activeWork); setNextRefreshDelayMs(routePollDelayMs ?? (activeWork ? activeIndexingPollFallbackMs : null)); })(); - refreshInFlightRef.current = { epoch: authRequest.epoch, promise }; + refreshInFlightRef.current = { epoch: authRequest.epoch, dataScope: requestedDataScope, promise }; try { return await promise; } finally { authRequest.release(); - if ((options.includeDashboardData ?? true) === true && canCommit()) setDashboardDataLoading(false); + if (requestedDataScope !== 0 && canCommit()) setDashboardDataLoading(false); if (refreshInFlightRef.current?.promise === promise) { refreshInFlightRef.current = null; } @@ -1267,6 +1301,8 @@ export function ClinicalDashboard({ if (!response.ok) { throw new Error(typeof payload.error === "string" ? payload.error : "Job retry could not be started."); } + setUserStartedIngestion(true); + setIndexingActive(true); setActionNotice({ tone: "success", message: "Ingestion job retry queued.", @@ -1312,6 +1348,8 @@ export function ClinicalDashboard({ : "Document reindex could not be started.", ); } + setUserStartedIngestion(true); + setIndexingActive(true); setActionNotice({ tone: "success", message: mode === "enrichment" ? "Document enrichment refreshed." : "Document reindex queued.", @@ -1475,32 +1513,79 @@ export function ClinicalDashboard({ [documents, jobs, batches, indexingActive], ); const needsSetupRecheck = useMemo(() => setupNeedsSlowRecheck(setupChecks), [setupChecks]); + const dashboardDataSurfaceVisible = documentsDrawerOpen || uploadDrawerOpen; + const administrationSurfaceVisible = uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin"); useEffect(() => { - refresh({ includeSetup: true, includeDashboardData: true, includeDocumentMeta: true }).catch(() => undefined); + dashboardDataLoadedRef.current = false; + administrationDataLoadedRef.current = false; + }, [authEpoch]); + + useEffect(() => { + refresh({ includeSetup: true, includeDashboardData: false, includeDocumentMeta: false }).catch(() => undefined); }, [authStatus, authorizationHeader, clientDemoMode, refresh]); useEffect(() => { - const hasScheduledWork = activeIndexingWork || needsSetupRecheck; - if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) { + const includeDashboardData = dashboardDataSurfaceVisible && !dashboardDataLoadedRef.current; + const includeAdministrationData = administrationSurfaceVisible && !administrationDataLoadedRef.current; + if (!includeDashboardData && !includeAdministrationData) return; + refresh({ + includeSetup: false, + includeDashboardData, + includeAdministrationData, + includeDocumentMeta: includeDashboardData, + }).catch(() => undefined); + }, [administrationSurfaceVisible, authEpoch, dashboardDataSurfaceVisible, refresh]); + + useEffect(() => { + if (!userStartedIngestion || !dashboardDataLoadedRef.current || activeIndexingWork) return; + let cancelled = false; + queueMicrotask(() => { + if (!cancelled) setUserStartedIngestion(false); + }); + return () => { + cancelled = true; + }; + }, [activeIndexingWork, userStartedIngestion]); + + useEffect(() => { + const visibleSurfaceHasActiveWork = dashboardDataSurfaceVisible && activeIndexingWork; + const userOperationHasActiveWork = userStartedIngestion && activeIndexingWork; + const shouldPollDashboardData = visibleSurfaceHasActiveWork || userOperationHasActiveWork; + const hasScheduledWork = shouldPollDashboardData || needsSetupRecheck; + const pollingAllowed = + userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork); + if (!pollingAllowed) { return; } - const delay = activeIndexingWork ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs; + const delay = shouldPollDashboardData ? (nextRefreshDelayMs ?? activeIndexingPollFallbackMs) : setupRecheckPollMs; const timeout = window.setTimeout(() => { - if (!shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork)) { + const stillAllowed = + userOperationHasActiveWork || shouldPollForUpdates(demoMode, document.visibilityState, hasScheduledWork); + if (!stillAllowed) { return; } refresh({ - includeSetup: !activeIndexingWork, - includeDashboardData: activeIndexingWork, + includeSetup: !shouldPollDashboardData, + includeDashboardData: shouldPollDashboardData, + includeAdministrationData: shouldPollDashboardData && (administrationSurfaceVisible || userStartedIngestion), includeDocumentMeta: false, }).catch(() => undefined); }, delay); return () => window.clearTimeout(timeout); - }, [activeIndexingWork, demoMode, needsSetupRecheck, nextRefreshDelayMs, refresh]); + }, [ + activeIndexingWork, + administrationSurfaceVisible, + dashboardDataSurfaceVisible, + demoMode, + needsSetupRecheck, + nextRefreshDelayMs, + refresh, + userStartedIngestion, + ]); useEffect(() => { const refreshVisibleDashboard = () => { @@ -1510,7 +1595,8 @@ export function ClinicalDashboard({ refresh({ includeSetup: true, - includeDashboardData: activeIndexingWork || canUsePrivateApis || clientDemoMode, + includeDashboardData: dashboardDataSurfaceVisible || (userStartedIngestion && activeIndexingWork), + includeAdministrationData: administrationSurfaceVisible || (userStartedIngestion && activeIndexingWork), includeDocumentMeta: false, }).catch(() => undefined); }; @@ -1521,7 +1607,7 @@ export function ClinicalDashboard({ document.removeEventListener("visibilitychange", refreshVisibleDashboard); window.removeEventListener("focus", refreshVisibleDashboard); }; - }, [activeIndexingWork, canUsePrivateApis, clientDemoMode, refresh]); + }, [activeIndexingWork, administrationSurfaceVisible, dashboardDataSurfaceVisible, refresh, userStartedIngestion]); useEffect(() => { const updateOnline = () => setIsOnline(navigator.onLine); @@ -2567,6 +2653,8 @@ export function ClinicalDashboard({ const payload = await response.json().catch(() => ({})); if (!isAuthEpochCurrent(requestEpoch)) return; if (!response.ok) throw new Error(payload.error || errorCopy.bulkReindexFailed); + setUserStartedIngestion(true); + setIndexingActive(true); setBulkActionStatus( `${payload.results?.filter((result: { ok: boolean }) => result.ok).length ?? 0} selected documents updated.`, ); @@ -3174,6 +3262,8 @@ export function ClinicalDashboard({ }, ]; const handleUploadQueued = () => { + setUserStartedIngestion(true); + setIndexingActive(true); setUploadMobileTab("jobs"); void refresh({ includeSetup: false, includeDashboardData: true, includeDocumentMeta: false }); }; diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 2ac217e8c..35e08602a 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -103,6 +103,7 @@ import { } from "@/lib/document-summary-formatting"; import { buildDocumentSummaryBadges } from "@/lib/document-summary-badges"; import { documentSummaryQuestion } from "@/lib/answer-contract"; +import type { DocumentDetailPayload } from "@/lib/document-detail-contract"; type PageRow = { id: string; @@ -230,39 +231,6 @@ async function requestSignedUrlPayload( return payload; } -// Fetch the preview + download signed URLs together (serving the client cache -// first when allowed). Returns their settled results for the caller to apply. -function fetchSignedUrlPair( - signedUrlEndpoint: string, - downloadSignedUrlEndpoint: string, - options: { - signal: AbortSignal; - headers: HeadersInit | undefined; - onUnauthorized: () => void; - useCache: boolean; - }, -) { - const cachedSigned = options.useCache ? getCachedSignedUrl(signedUrlEndpoint) : null; - const cachedDownload = options.useCache ? getCachedSignedUrl(downloadSignedUrlEndpoint) : null; - const signedUrlRequest: Promise = cachedSigned - ? Promise.resolve(cachedSigned) - : requestSignedUrlPayload(signedUrlEndpoint, { - signal: options.signal, - headers: options.headers, - onUnauthorized: options.onUnauthorized, - errorMessage: "Source preview could not be loaded.", - }); - const signedDownloadUrlRequest: Promise = cachedDownload - ? Promise.resolve(cachedDownload) - : requestSignedUrlPayload(downloadSignedUrlEndpoint, { - signal: options.signal, - headers: options.headers, - onUnauthorized: options.onUnauthorized, - errorMessage: "Download URL could not be loaded.", - }); - return Promise.allSettled([signedUrlRequest, signedDownloadUrlRequest]); -} - function getInitialPdfViewerMode() { if (typeof window === "undefined") { return { @@ -290,6 +258,12 @@ function getInitialPdfViewerMode() { }; } +function rowsById(incoming: T[]) { + const rows = new Map(); + for (const row of incoming) rows.set(row.id, row); + return Array.from(rows.values()); +} + function hasProfileItems(items: unknown): items is DocumentSummaryProfileItem[] { return Array.isArray(items) && items.some((item) => item && typeof item === "object" && "text" in item); } @@ -602,7 +576,7 @@ function DocumentViewerAnchors({ className, }: { evidenceHref: "#source-evidence" | "#source-evidence-rail"; - textHref: "#source-text-mobile" | "#source-text-desktop"; + textHref: "#source-text"; className?: string; }) { const anchors = [ @@ -897,7 +871,7 @@ const IndexedTextPanel = memo(function IndexedTextPanel({ searchingDocument: boolean; documentSearchError: string | null; idPrefix: string; - sectionId?: "source-text-mobile" | "source-text-desktop"; + sectionId?: "source-text"; selectedChunkId?: string; onSearchChange: (value: string) => void; }) { @@ -1412,12 +1386,33 @@ function documentKeySections(document: ClinicalDocument) { return Array.from(new Set(labels)).slice(0, 3); } -function DocumentPagePreview({ href, pageNumber }: { href: string; pageNumber: number | null }) { +function DocumentPagePreview({ + href, + pageNumber, + onNavigate, +}: { + href: string; + pageNumber: number | null; + onNavigate: (page: number) => void; +}) { // A real "jump to page" chip rather than a fake wireframe thumbnail that looks // like a skeleton that never resolves. return ( { + if ( + pageNumber === null || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return; + event.preventDefault(); + onNavigate(pageNumber); + }} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-sm font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/40 hover:bg-[color:var(--clinical-accent-soft)] hover:text-[color:var(--clinical-accent)]" >
on phones. - // Scroll the copy the user can actually see: skip display:none matches and - // expand the mobile
when the pinned chunk only exists inside it. - const matches = Array.from( - window.document.querySelectorAll(`[data-source-chunk-id="${CSS.escape(chunkId)}"]`), - ); - const isDisplayed = (element: HTMLElement) => element.offsetParent !== null || element.getClientRects().length > 0; - const inClosedDetails = (element: HTMLElement) => Boolean(element.closest("details:not([open])")); - let target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element)); - if (!target) { - const collapsed = matches - .map((element) => element.closest("details")) - .find((node): node is HTMLDetailsElement => node instanceof HTMLDetailsElement && !node.open); - if (collapsed) collapsed.open = true; - target = matches.find((element) => isDisplayed(element) && !inClosedDetails(element)) ?? matches[0]; - } - target?.scrollIntoView({ block: "center", behavior: "smooth" }); - }, [chunkId, loadingDocument, chunks.length]); + if (!activeChunkId || loadingDocument) return; + window.document + .querySelector(`[data-source-chunk-id="${CSS.escape(activeChunkId)}"]`) + ?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [activeChunkId, loadingDocument, chunks.length]); const retryPreview = () => { setViewerError(null); setPreviewError(null); + setDownloadError(null); + // Re-open the guarded load path after a transient identity failure; the + // cleared identity promise is still revalidated before any API request. + setLocalProjectReady(true); setLoadingDocument(true); setPreviewAttempt((current) => current + 1); }; @@ -2300,8 +2425,8 @@ export function DocumentViewer({ signedUrlRefreshCountRef.current = 0; }, [documentId]); // The PDF signed URL has a 10-min TTL and pdf.js holds a dead reference once it - // expires. When the canvas reports an expiry, drop the cached URLs and re-run - // the fetch pipeline to mint fresh ones (bounded so a broken URL can't loop). + // expires. When the canvas reports an expiry, drop cached URLs and mint a fresh + // preview only (bounded so a broken URL can't loop). Download remains click-gated. // Stable identity (useCallback) so the memoised PdfCanvasViewer isn't re-rendered // — and its page re-rastered — every time an unrelated parent state (source-search // keystroke, composer focus, online/offline) changes. @@ -2311,6 +2436,7 @@ export function DocumentViewer({ const signedUrlEndpoint = `/api/documents/${documentId}/signed-url`; clearCachedSignedUrl(signedUrlEndpoint); clearCachedSignedUrl(`${signedUrlEndpoint}?download=true`); + setDownloadSignedUrl(null); refreshSignedUrls(); }, [documentId, refreshSignedUrls]); // A successful reload means the refreshed URL was accepted, so the recovery @@ -2490,7 +2616,24 @@ export function DocumentViewer({ - ) : null} + ) : ( + + )} {useNativePdfViewer ? ( - + ) : ( { - router.push(documentPageHref(documentId, page), { scroll: false }); - }} + onPageChange={navigateToPage} /> )} @@ -2706,55 +2855,15 @@ export function DocumentViewer({ -
- -
- - - - - - Indexed page text - - {effectiveLoadingDocument - ? "Loading indexed page text" - : `Page ${selectedPage?.page_number ?? initialPage} extracted text`} - - - - -
- -
-
-
- -
+
+
+ +
@@ -2784,11 +2893,7 @@ export function DocumentViewer({ ) : null}
- + ({ - label: preset.query.replace(/\bdifferential diagnosis\b/i, "").trim() || preset.query, - query: preset.query.includes("differential") ? preset.query : `${preset.query} differential diagnosis`, - icon: BrainCircuit, - })); +const recentDifferentials: RecentDifferential[] = defaultDifferentialRecentQueries.map((query) => ({ + label: query.replace(/\bdifferential diagnosis\b/i, "").trim() || query, + query: query.includes("differential") ? query : `${query} differential diagnosis`, + icon: BrainCircuit, +})); const candidateIconBySlug: Array<[string, LucideIcon]> = [ ["substance", FlaskConical], diff --git a/src/components/clinical-dashboard/use-universal-search.ts b/src/components/clinical-dashboard/use-universal-search.ts index dabdbbd6d..cdca941e9 100644 --- a/src/components/clinical-dashboard/use-universal-search.ts +++ b/src/components/clinical-dashboard/use-universal-search.ts @@ -11,6 +11,7 @@ import type { // Value import from the leaf module only: universal-search.ts itself is server-only // (snapshot catalogues, rag, supabase) and must never enter the client bundle. import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; +import { consumeUniversalSearchNdjson } from "@/lib/universal-search-stream"; import type { AppModeId } from "@/lib/app-modes"; import { useAuthSession } from "@/lib/supabase/client"; @@ -34,6 +35,7 @@ export type UniversalSearchState = { type UniversalSearchResult = { groups: UniversalSearchGroup[]; query: string; + complete: boolean; interpretation?: UniversalSearchInterpretation; domainOrder?: UniversalSearchDomain[]; topHit?: UniversalSearchTopHit; @@ -102,7 +104,7 @@ export function useUniversalSearch(args: { limitPerDomain?: number; }): UniversalSearchState { const { authorizationHeader } = useAuthSession(); - const [result, setResult] = useState({ groups: [], query: "" }); + const [result, setResult] = useState({ groups: [], query: "", complete: true }); const requestSeqRef = useRef(0); const prevAuthRef = useRef(authorizationHeader); const trimmedQuery = args.query.trim(); @@ -126,7 +128,7 @@ export function useUniversalSearch(args: { const key = cacheKey; if (authChanged) { - setResult({ groups: [], query: "" }); + setResult({ groups: [], query: "", complete: true }); } // Instant path: a previously fetched query needs no fetch. The render below reads the cache @@ -151,19 +153,43 @@ export function useUniversalSearch(args: { limit: String(limitPerDomain), domains: domains.join(","), mode: args.contextMode, + stream: "ndjson", }); fetch(`/api/search/universal?${params.toString()}`, { headers: authorizationHeader, signal: controller.signal }) .then(async (response) => { if (requestId !== requestSeqRef.current) return; if (!response.ok) { - setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode }); + setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode, complete: true }); return; } - const payload = (await response.json()) as Partial; - if (requestId !== requestSeqRef.current) return; + const payload = await consumeUniversalSearchNdjson(response, { + signal: controller.signal, + onGroup: (group, streamedQuery) => { + if (controller.signal.aborted || requestId !== requestSeqRef.current || streamedQuery !== trimmedQuery) { + return; + } + + setResult((current) => { + if (controller.signal.aborted || requestId !== requestSeqRef.current) return current; + const groups = + current.query === trimmedQuery && current.contextMode === args.contextMode && !current.complete + ? current.groups.filter((candidate) => candidate.kind !== group.kind) + : []; + if (!group.error && group.items.length > 0) groups.push(group); + return { + groups, + query: trimmedQuery, + contextMode: args.contextMode, + complete: false, + }; + }); + }, + }); + if (controller.signal.aborted || requestId !== requestSeqRef.current) return; const next: UniversalSearchResult = { groups: (payload.groups ?? []).filter((group) => !group.error && group.items.length > 0), query: trimmedQuery, + complete: true, interpretation: payload.interpretation, domainOrder: payload.domainOrder, topHit: payload.topHit, @@ -176,9 +202,9 @@ export function useUniversalSearch(args: { }) .catch((error: unknown) => { // An aborted fetch is a superseded keystroke, not a failure — leave state to the newer request. - if (error instanceof DOMException && error.name === "AbortError") return; + if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return; if (requestId !== requestSeqRef.current) return; - setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode }); + setResult({ groups: [], query: trimmedQuery, contextMode: args.contextMode, complete: true }); }); }, debounceMs); @@ -209,7 +235,7 @@ export function useUniversalSearch(args: { const fresh = result.query === trimmedQuery && result.contextMode === args.contextMode; return { groups: fresh ? result.groups : [], - loading: !fresh, + loading: !fresh || !result.complete, query: result.query, interpretation: fresh ? result.interpretation : undefined, domainOrder: fresh ? result.domainOrder : undefined, diff --git a/src/components/document-viewer-lazy.tsx b/src/components/document-viewer-lazy.tsx index 26a39fa0e..c5ebc83fc 100644 --- a/src/components/document-viewer-lazy.tsx +++ b/src/components/document-viewer-lazy.tsx @@ -1,9 +1,5 @@ "use client"; -import dynamic from "next/dynamic"; - -// `ssr: false` requires a Client Component in the App Router; this wrapper -// keeps the viewer bundle browser-only for the server-rendered document page. -export const DocumentViewerLazy = dynamic(() => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), { - ssr: false, -}); +// Retain the compatibility export while allowing Next.js to prerender the +// browser-guarded Client Component with the Server Component's initial detail. +export { DocumentViewer as DocumentViewerLazy } from "@/components/DocumentViewer"; diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index 8016e9c04..ae022e777 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -44,9 +44,8 @@ import { toneWarning, } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; -import { formCatalogDetails } from "@/lib/form-catalog"; -import type { FormRecord } from "@/lib/forms"; -import type { ServiceChipTone, ServiceContact, ServiceCriterion, ServiceSummaryCard } from "@/lib/services"; +import { formCatalogDetails, type FormRecord } from "@/lib/form-ranker"; +import type { ServiceChipTone, ServiceContact, ServiceCriterion, ServiceSummaryCard } from "@/lib/service-ranker"; import { readSavedRegistrySlugs, savedFormsStorageKey, writeSavedRegistrySlugs } from "@/lib/saved-registry-storage"; const missingText = "Not listed"; diff --git a/src/components/forms/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index d386ca447..9a32ccf65 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -19,8 +19,7 @@ import { import { useId, useMemo, useState } from "react"; import { appModeHomeHref } from "@/lib/app-modes"; -import { formCatalogDetails } from "@/lib/form-catalog"; -import { rankFormRecords, type FormSearchMatch } from "@/lib/forms"; +import { formCatalogDetails, rankFormRecords, type FormSearchMatch } from "@/lib/form-ranker"; import { useRegistryRecords, type RegistryRequestStatus } from "@/lib/use-registry-records"; import { cn, diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 365b6a8ef..ccf02b9d9 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -50,7 +50,7 @@ import { type ServiceRecord, type ServiceStatusChip, type ServiceSummaryCard, -} from "@/lib/services"; +} from "@/lib/service-ranker"; import { readSavedRegistrySlugs, savedServicesStorageKey, writeSavedRegistrySlugs } from "@/lib/saved-registry-storage"; const missingText = "Not listed"; diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index bd1c68cd1..87b090573 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -33,7 +33,7 @@ import { useSearchCommand } from "@/components/clinical-dashboard/search-command import { appModeHomeHref } from "@/lib/app-modes"; import { recordMatchesCommandScopes } from "@/lib/search-command-surface"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { rankServiceRecords, serviceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/services"; +import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { sortResultItems } from "@/lib/result-sort"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; @@ -475,13 +475,15 @@ export function ServicesNavigatorPage() { }); return map; }, [scopedMatches]); - const [selectedSlugs, setSelectedSlugs] = useState(() => serviceRecords.slice(0, 2).map((service) => service.slug)); - const selected = searchableRecords.filter((service) => selectedSlugs.includes(service.slug)); + const [selectedSlugs, setSelectedSlugs] = useState(null); + const effectiveSelectedSlugs = selectedSlugs ?? searchableRecords.slice(0, 2).map((service) => service.slug); + const selected = searchableRecords.filter((service) => effectiveSelectedSlugs.includes(service.slug)); function toggleSelected(slug: string) { - setSelectedSlugs((current) => - current.includes(slug) ? current.filter((item) => item !== slug) : [slug, ...current].slice(0, 5), - ); + setSelectedSlugs((current) => { + const selected = current ?? effectiveSelectedSlugs; + return selected.includes(slug) ? selected.filter((item) => item !== slug) : [slug, ...selected].slice(0, 5); + }); } function applyServiceQuery(nextQuery: string) { @@ -634,7 +636,7 @@ export function ServicesNavigatorPage() { service={service} index={index} relevanceRank={sortValue === "alpha" ? null : (relevanceRankMap.get(service.slug) ?? null)} - selected={selectedSlugs.includes(service.slug)} + selected={effectiveSelectedSlugs.includes(service.slug)} onToggleSelected={toggleSelected} /> ))} diff --git a/src/lib/answer-client-payload.ts b/src/lib/answer-client-payload.ts index 7e09f7095..6785af5fd 100644 --- a/src/lib/answer-client-payload.ts +++ b/src/lib/answer-client-payload.ts @@ -9,10 +9,12 @@ import type { RagAnswer, SearchResult } from "@/lib/types"; // caches, generation inputs, and eval behavior byte-identical while cutting // the final SSE/JSON event the user waits on after the prose has streamed. // -// Ordering contract: sourceGovernanceWarnings and logAnswerDiagnostics consume -// the FULL answer and must run before this trim (both routes do). Full source -// content remains client-visible because the safety panel scans it for clinical -// warnings that may occur beyond the display synopsis. +// Ordering contract: source-governance/safety derivation and diagnostics consume +// the FULL answer and must run before this trim (both routes do). The browser +// receives only the same bounded snippet the source UI renders; explicit +// server-derived safetyWarnings preserve findings beyond that display window. + +const clientSourceSnippetMaxChars = 900; const sourceFieldPolicy = { id: "client", @@ -48,7 +50,7 @@ const sourceFieldPolicy = { table_facts: "server", index_unit: "server", indexing_quality: "client", - images: "client", + images: "server", } as const satisfies Record; function trimSourceForClient(source: SearchResult): SearchResult { @@ -57,8 +59,15 @@ function trimSourceForClient(source: SearchResult): SearchResult { .filter((key) => sourceFieldPolicy[key] === "client" && key in source) .map((key) => [key, source[key]]), ) as SearchResult; - trimmed.content = source.content ?? ""; - trimmed.images ??= []; + const renderedSnippet = (source.retrieval_synopsis ?? source.content ?? "").trim(); + trimmed.content = + renderedSnippet.length <= clientSourceSnippetMaxChars + ? renderedSnippet + : `${renderedSnippet.slice(0, clientSourceSnippetMaxChars - 1).trimEnd()}…`; + if (source.retrieval_synopsis != null) trimmed.retrieval_synopsis = trimmed.content; + // Full image/table objects remain available to generation and diagnostics, + // but the answer UI resolves source media from the bounded image_ids list. + trimmed.images = []; return trimmed; } diff --git a/src/lib/answer-response.ts b/src/lib/answer-response.ts index 7245b69b1..e603c3fe1 100644 --- a/src/lib/answer-response.ts +++ b/src/lib/answer-response.ts @@ -1,10 +1,28 @@ import { toClientAnswerPayload } from "@/lib/answer-client-payload"; +import { extractSafetyFindings } from "@/lib/clinical-safety"; import { hasDangerSourceGovernanceWarning, sourceGovernanceRefusalAnswer, sourceGovernanceWarnings, } from "@/lib/source-governance"; -import type { RagAnswer } from "@/lib/types"; +import type { RagAnswer, SafetyWarning } from "@/lib/types"; + +function clientSafetyWarning(warning: SafetyWarning): SafetyWarning { + const citation = warning.citation; + return { + ...warning, + citation: { + chunk_id: citation.chunk_id, + document_id: citation.document_id, + title: citation.title, + file_name: citation.file_name, + page_number: citation.page_number, + chunk_index: citation.chunk_index, + ...(citation.similarity === undefined ? {} : { similarity: citation.similarity }), + ...(citation.provenance === undefined ? {} : { provenance: citation.provenance }), + }, + }; +} export function answerDegradedModeSignal( answer?: Pick, @@ -19,6 +37,7 @@ export function answerDegradedModeSignal( /** Apply the shared browser-boundary source-governance contract. */ export function buildGovernedAnswerClientResponse(answer: RagAnswer) { + const safetyWarnings = extractSafetyFindings(answer).map(clientSafetyWarning); const warnings = sourceGovernanceWarnings({ results: answer.sources ?? [], relevance: answer.relevance ?? answer.smartPanel?.relevance ?? null, @@ -55,6 +74,7 @@ export function buildGovernedAnswerClientResponse(answer: RagAnswer) { sources: [], degradedMode: answerDegradedModeSignal(answer), sourceGovernanceWarnings: warnings, + safetyWarnings: [], }, }; } @@ -67,6 +87,7 @@ export function buildGovernedAnswerClientResponse(answer: RagAnswer) { ...toClientAnswerPayload(answer), degradedMode: answerDegradedModeSignal(answer), sourceGovernanceWarnings: warnings, + safetyWarnings, }, }; } diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 04b7472f2..fb8b5a68a 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -78,6 +78,7 @@ const anonymousApiRateLimitDefaults: Partial; type RateLimitRpcRow = { + bucket?: string | null; limited?: boolean; limit_value?: number; remaining?: number; @@ -270,6 +271,61 @@ export async function consumeSubjectApiRateLimit(args: { }; } +export type SummaryRateLimitBucket = "answer" | "document_summarize"; + +export type SummaryRateLimitDecision = { + bucket: SummaryRateLimitBucket | null; + rateLimit: ApiRateLimitResult; +}; + +/** + * Atomically applies the answer and document-summary policies used by streamed + * summaries. The database function locks every participating bucket in a + * stable order, avoiding the partial accounting and lock-order risk of two + * serial RPC calls. + */ +export async function consumeSummaryRateLimits(args: { + supabase: SupabaseAdmin; + subject: RateLimitSubject; +}): Promise { + const answerDefaults = + args.subject.kind === "owner" + ? apiRateLimitDefaults.answer + : (anonymousApiRateLimitDefaults.answer ?? apiRateLimitDefaults.answer); + const summaryDefaults = apiRateLimitDefaults.document_summarize; + const globalAnswerDefaults = apiRateLimitDefaults.answer; + const { data, error } = await args.supabase.rpc("consume_summary_rate_limits_atomic", { + p_owner_id: args.subject.kind === "owner" ? args.subject.ownerId : null, + p_subject_key: args.subject.kind === "anonymous" ? args.subject.subjectKey : null, + p_answer_limit: answerDefaults.limit, + p_answer_window_seconds: answerDefaults.windowSeconds, + p_summary_limit: summaryDefaults.limit, + p_summary_window_seconds: summaryDefaults.windowSeconds, + p_global_answer_limit: globalAnswerDefaults.limit, + p_global_answer_window_seconds: globalAnswerDefaults.windowSeconds, + }); + + if (error) throw new ApiRateLimitUnavailableError(); + + const row = parseRateLimitRow(data); + const bucket = row?.bucket; + const validBucket = bucket === "answer" || bucket === "document_summarize" ? bucket : null; + if (!row || typeof row.limited !== "boolean" || (row.limited && validBucket === null)) { + throw new ApiRateLimitUnavailableError(); + } + + return { + bucket: validBucket, + rateLimit: { + limited: row.limited, + limit: Number(row.limit_value ?? summaryDefaults.limit), + remaining: Number(row.remaining ?? 0), + retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? summaryDefaults.windowSeconds)), + resetAt: String(row.reset_at ?? new Date(Date.now() + summaryDefaults.windowSeconds * 1000).toISOString()), + }, + }; +} + function consumeInMemoryApiRateLimit({ ownerId, bucket, @@ -320,6 +376,7 @@ export function rateLimitJsonResponse(message: string, rateLimit: ApiRateLimitRe { status: 429, headers: { + "Cache-Control": "private, no-store", "Retry-After": String(rateLimit.retryAfterSeconds), }, }, diff --git a/src/lib/clinical-safety.ts b/src/lib/clinical-safety.ts index 2b970b6da..b1d1bed47 100644 --- a/src/lib/clinical-safety.ts +++ b/src/lib/clinical-safety.ts @@ -6,19 +6,10 @@ import { sourceTextForCompactDisplay, sourceTextForDisplay, } from "@/lib/source-text-sanitizer"; -import type { Citation, RagAnswer, SearchResult } from "@/lib/types"; - -export type SafetyFindingKind = - "contraindication" | "red_flag" | "escalation" | "dose_limit" | "monitoring" | "exclusion" | "caveat"; - -export type SafetyFinding = { - id: string; - kind: SafetyFindingKind; - label: string; - text: string; - citation: Citation; - href: string; -}; +import type { Citation, RagAnswer, SafetyWarning, SafetyWarningKind, SearchResult } from "@/lib/types"; + +export type SafetyFindingKind = SafetyWarningKind; +export type SafetyFinding = SafetyWarning; const safetyPatterns: Array<{ kind: SafetyFindingKind; label: string; pattern: RegExp }> = [ { @@ -95,6 +86,7 @@ function hasQueryConceptOverlap(text: string, terms: string[]) { } export function extractSafetyFindings(answer: RagAnswer | null | undefined, limit = 5): SafetyFinding[] { + if (answer?.safetyWarnings) return answer.safetyWarnings.slice(0, limit); if (!answer?.grounded) return []; if (answer.relevance && !answer.relevance.isSourceBacked) return []; diff --git a/src/lib/corpus-grounding.ts b/src/lib/corpus-grounding.ts index 57b2c2d55..f86d900c7 100644 --- a/src/lib/corpus-grounding.ts +++ b/src/lib/corpus-grounding.ts @@ -9,6 +9,20 @@ import { type RetrievalAccessScope, } from "@/lib/owner-scope"; +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"); +} + +async function resolveAbortableQuery(query: AbortableQuery, signal?: AbortSignal): Promise { + if (signal?.aborted) throw abortReason(signal); + const pending = signal && typeof query.abortSignal === "function" ? query.abortSignal(signal) : query; + const result = await pending; + if (signal?.aborted) throw abortReason(signal); + return result; +} + // Finding #11 (corpus-grounded relevance): the deterministic query analyzer cannot tell an // in-corpus bare topic ("bipolar disorder") from an invented one ("florbizone syndrome // management") — both land in the unsupported soft tail with identical confidence, and the LLM @@ -121,7 +135,9 @@ export async function classifyCorpusGrounding(args: { // The exact owner_filter retrieval will use (null = unscoped, zero-UUID = public docs only). ownerFilter: string | null; accessScope?: RetrievalAccessScope; + signal?: AbortSignal; }): Promise { + if (args.signal?.aborted) throw abortReason(args.signal); const terms = corpusGroundingTerms(args.query); if (terms.length === 0) return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] }; @@ -142,20 +158,29 @@ export async function classifyCorpusGrounding(args: { if (missing.length > 0) { try { const ownerFilter = accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL; - const versioned = await args.supabase.rpc("corpus_topic_term_stats_v2", { - terms: missing, - owner_filter: ownerFilter, - include_public: accessScope.includePublic, - }); + const versioned = await resolveAbortableQuery( + args.supabase.rpc("corpus_topic_term_stats_v2", { + terms: missing, + owner_filter: ownerFilter, + include_public: accessScope.includePublic, + }), + args.signal, + ); const calls = !versioned || isMissingRetrievalRpcError(versioned.error) ? await Promise.all([ - args.supabase.rpc("corpus_topic_term_stats", { terms: missing, owner_filter: ownerFilter }), + resolveAbortableQuery( + args.supabase.rpc("corpus_topic_term_stats", { terms: missing, owner_filter: ownerFilter }), + args.signal, + ), accessScope.ownerId && accessScope.includePublic - ? args.supabase.rpc("corpus_topic_term_stats", { - terms: missing, - owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, - }) + ? resolveAbortableQuery( + args.supabase.rpc("corpus_topic_term_stats", { + terms: missing, + owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, + }), + args.signal, + ) : Promise.resolve({ data: [], error: null }), ]) : [versioned]; @@ -182,11 +207,13 @@ export async function classifyCorpusGrounding(args: { // A term the RPC did not echo back got dropped SQL-side (blank after trim); treat the // whole classification as inconclusive rather than guessing. if (rows.length !== missing.length) return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] }; + if (args.signal?.aborted) throw abortReason(args.signal); for (const row of rows) { storeCachedStats(ownerScopeKey, row); stats.push(row); } } catch { + if (args.signal?.aborted) throw abortReason(args.signal); // Fail open: missing RPC (migration not applied), transient DB error, demo mode — the // caller keeps today's behaviour (LLM classifier fallback + soft-tail short-circuit). return { verdict: "inconclusive", anchorTerms: [], absentTerms: [] }; diff --git a/src/lib/cross-mode-links.ts b/src/lib/cross-mode-links.ts index 7da1b82ce..1ac555613 100644 --- a/src/lib/cross-mode-links.ts +++ b/src/lib/cross-mode-links.ts @@ -1,5 +1,5 @@ import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; -import { rankFormRecords, type FormRecord } from "@/lib/forms"; +import { rankFormRecords, type FormRecord } from "@/lib/form-ranker"; import { medicationIdentityBadges, medicationIndication, @@ -7,7 +7,7 @@ import { type MedicationRecord, } from "@/lib/medications"; import { extractKeywordTerms } from "@/lib/keyword-query"; -import { rankServiceRecords, type ServiceRecord } from "@/lib/services"; +import { rankServiceRecords, type ServiceRecord } from "@/lib/service-ranker"; export type CrossModeLinkModeId = Extract; diff --git a/src/lib/deep-memory.ts b/src/lib/deep-memory.ts index aee3bbc7c..9a7dd551c 100644 --- a/src/lib/deep-memory.ts +++ b/src/lib/deep-memory.ts @@ -34,6 +34,24 @@ import type { SearchResult, } from "@/lib/types"; +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 resolveAbortableQuery(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; +} + export const ragDeepMemoryVersion = "rag-deep-memory-v1" as const; export const localDeepMemoryProducer = "local-worker" as const; @@ -893,18 +911,23 @@ export async function fetchMemoryCardsForQuery(args: { accessScope?: RetrievalAccessScope; documentIds?: string[]; matchCount?: number; + signal?: AbortSignal; }) { try { + throwIfAborted(args.signal); const accessScope = retrievalAccessScopeForArgs(args); if (args.queryEmbedding?.length) { - const versioned = await args.supabase.rpc("match_document_memory_cards_hybrid_v3", { - query_embedding: args.queryEmbedding, - query_text: buildClinicalTextSearchQuery(args.query), - match_count: args.matchCount ?? 32, - min_similarity: 0.1, - document_filters: args.documentIds?.length ? args.documentIds : null, - ...retrievalRpcScopeArgs(accessScope), - }); + const versioned = await resolveAbortableQuery( + args.supabase.rpc("match_document_memory_cards_hybrid_v3", { + query_embedding: args.queryEmbedding, + query_text: buildClinicalTextSearchQuery(args.query), + match_count: args.matchCount ?? 32, + min_similarity: 0.1, + document_filters: args.documentIds?.length ? args.documentIds : null, + ...retrievalRpcScopeArgs(accessScope), + }), + args.signal, + ); let data = versioned?.data; let error = versioned?.error; if (!versioned || isMissingRetrievalRpcError(versioned.error)) { @@ -915,16 +938,22 @@ export async function fetchMemoryCardsForQuery(args: { min_similarity: 0.1, document_filters: args.documentIds?.length ? args.documentIds : null, }; - const ownerResult = await args.supabase.rpc("match_document_memory_cards_hybrid_v2", { - ...baseArgs, - owner_filter: accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL, - }); + const ownerResult = await resolveAbortableQuery( + args.supabase.rpc("match_document_memory_cards_hybrid_v2", { + ...baseArgs, + owner_filter: accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL, + }), + args.signal, + ); const publicResult = accessScope.ownerId && accessScope.includePublic - ? await args.supabase.rpc("match_document_memory_cards_hybrid_v2", { - ...baseArgs, - owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, - }) + ? await resolveAbortableQuery( + args.supabase.rpc("match_document_memory_cards_hybrid_v2", { + ...baseArgs, + owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, + }), + args.signal, + ) : { data: [], error: null }; error = ownerResult.error ?? publicResult.error; type MemoryCardHybridRow = { id: string; hybrid_score: number; rrf_score?: number | null } & Record< @@ -999,7 +1028,7 @@ export async function fetchMemoryCardsForQuery(args: { } if (args.documentIds?.length) queryBuilder = queryBuilder.in("document_id", args.documentIds); - const { data, error } = await queryBuilder; + const { data, error } = await resolveAbortableQuery(queryBuilder, args.signal); if (error) return []; const cards = (data ?? []) as DocumentMemoryCard[]; @@ -1013,7 +1042,7 @@ export async function fetchMemoryCardsForQuery(args: { documentsQuery = documentsQuery.is("owner_id", null); } const { data: documents, error: documentsError } = documentIds.length - ? await documentsQuery + ? await resolveAbortableQuery(documentsQuery, args.signal) : { data: [], error: null }; const committedGenerationByDocument = new Map( (documents ?? []).map((document) => [document.id, committedIndexGeneration(document.metadata)] as const), @@ -1038,6 +1067,7 @@ export async function fetchMemoryCardsForQuery(args: { .sort((a, b) => scoreMemoryCardForQuery(args.query, b) - scoreMemoryCardForQuery(args.query, a)) .slice(0, args.matchCount ?? 32); } catch { + if (args.signal?.aborted) throw abortReason(args.signal); return []; } } diff --git a/src/lib/differential-search-composition.ts b/src/lib/differential-search-composition.ts new file mode 100644 index 000000000..aa075712d --- /dev/null +++ b/src/lib/differential-search-composition.ts @@ -0,0 +1,126 @@ +import type { DifferentialPresentationWorkflow, DifferentialRecord } from "@/lib/differential-snapshot"; + +export type DifferentialRecordMatch = { + record: DifferentialRecord; + score: number; + reasons: string[]; +}; + +/** Back-compat alias kept for the universal-search workstream naming. */ +export type DifferentialSearchMatch = DifferentialRecordMatch; + +export type DifferentialPresentationMatch = { + workflow: DifferentialPresentationWorkflow; + score: number; + reasons: string[]; +}; + +export type DifferentialSearchResultItem = { + id: string; + kind: "presentation" | "diagnosis"; + slug: string; + title: string; + subtitle: string; + href: string; + status: DifferentialRecord["status"]; + score: number; + matchLabel: "Best match" | "High match" | "Moderate match" | "Lower match"; + tags: string[]; + safety: string; + reasons: string[]; +}; + +function diagnosisResultItem(match: DifferentialRecordMatch): Omit { + const { record, score, reasons } = match; + return { + id: record.slug, + kind: "diagnosis", + slug: record.slug, + title: record.title, + subtitle: record.clinicalHinge || record.subtitle, + href: `/differentials/diagnoses/${record.slug}`, + status: record.status, + score, + tags: [...record.currentPresentation.slice(0, 3), record.investigations[0]] + .filter((value): value is string => Boolean(value?.trim())) + .slice(0, 4), + safety: record.safetySnapshot.summary, + reasons, + }; +} + +function presentationResultItem( + match: DifferentialPresentationMatch, +): Omit { + const { workflow, score, reasons } = match; + return { + id: workflow.id, + kind: "presentation", + slug: workflow.id, + title: workflow.title, + subtitle: workflow.subtitle, + href: `/differentials/presentations/${workflow.id}`, + status: workflow.status, + score, + tags: workflow.safetySnapshot.tags.slice(0, 4), + safety: workflow.safetySnapshot.summary, + reasons, + }; +} + +/** Compose ranked diagnosis + presentation matches into one adaptive result + * list: when a presentation matches about as strongly as the best diagnosis + * it leads (followed by its candidate diagnoses in ranked order), otherwise + * results interleave purely by score. Deduped by id, capped at `limit`. */ +export function composeDifferentialSearchResults( + diagnoses: DifferentialRecordMatch[], + presentations: DifferentialPresentationMatch[], + limit = 8, +): DifferentialSearchResultItem[] { + const items: Array> = []; + const seen = new Set(); + const push = (item: Omit) => { + const key = `${item.kind}:${item.id}`; + if (seen.has(key)) return; + seen.add(key); + items.push(item); + }; + + const topPresentation = presentations[0]; + const topDiagnosisScore = diagnoses[0]?.score ?? 0; + const presentationLeads = + Boolean(topPresentation) && (topDiagnosisScore === 0 || topPresentation!.score >= topDiagnosisScore * 0.8); + + if (topPresentation && presentationLeads) { + push(presentationResultItem(topPresentation)); + const candidateSlugs = new Set(topPresentation.workflow.candidates.map((candidate) => candidate.slug)); + for (const match of diagnoses) { + if (candidateSlugs.has(match.record.slug)) push(diagnosisResultItem(match)); + } + for (const match of diagnoses) push(diagnosisResultItem(match)); + for (const match of presentations.slice(1)) push(presentationResultItem(match)); + } else { + const merged = [ + ...diagnoses.map((match) => ({ score: match.score, item: diagnosisResultItem(match) })), + ...presentations.map((match) => ({ score: match.score, item: presentationResultItem(match) })), + ].sort((left, right) => right.score - left.score); + for (const entry of merged) push(entry.item); + } + + return items.slice(0, limit).map((item, index) => ({ + ...item, + matchLabel: + index === 0 ? "Best match" : item.score >= 12 ? "High match" : item.score >= 6 ? "Moderate match" : "Lower match", + })); +} + +// These are the same compact entry queries previously derived at module load +// from the generated snapshot. Keeping only the five rendered strings avoids +// shipping the full snapshot to the client for a small "Recent work" list. +export const defaultDifferentialRecentQueries = [ + "older adult acute confusion", + "first episode psychosis", + "perinatal mood psychosis", + "agitated intoxicated patient", + "withdrawal tremor autonomic", +] as const; diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index dec593abd..49750c037 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -2,6 +2,10 @@ import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; import { cleanDifferentialItem, type DifferentialDetailContext } from "@/lib/differential-detail"; import { loadDifferentialSnapshot } from "@/lib/differential-fixtures"; import { deriveGovernanceFromSnapshot } from "@/lib/differential-records"; +import { + type DifferentialPresentationMatch, + type DifferentialRecordMatch, +} from "@/lib/differential-search-composition"; import type { DifferentialComparisonCandidate, DifferentialComparisonCriterion, @@ -35,6 +39,13 @@ export type { DifferentialScenarioPreset, DifferentialSection, }; +export { + composeDifferentialSearchResults, + type DifferentialPresentationMatch, + type DifferentialRecordMatch, + type DifferentialSearchMatch, + type DifferentialSearchResultItem, +} from "@/lib/differential-search-composition"; function catalog() { return loadDifferentialSnapshot(); @@ -261,21 +272,6 @@ const differentialStatusRank: Record = { routine: 2, }; -export type DifferentialRecordMatch = { - record: DifferentialRecord; - score: number; - reasons: string[]; -}; - -/** Back-compat alias kept for the universal-search workstream naming. */ -export type DifferentialSearchMatch = DifferentialRecordMatch; - -export type DifferentialPresentationMatch = { - workflow: DifferentialPresentationWorkflow; - score: number; - reasons: string[]; -}; - function diagnosisHingeText(record: DifferentialRecord) { return normalizeSearchText( [record.subtitle, record.clinicalHinge, record.safetySnapshot.summary, ...record.safetySnapshot.tags].join(" "), @@ -414,105 +410,6 @@ export function rankPresentationWorkflows( })); } -export type DifferentialSearchResultItem = { - id: string; - kind: "presentation" | "diagnosis"; - slug: string; - title: string; - subtitle: string; - href: string; - status: DifferentialRecord["status"]; - score: number; - matchLabel: "Best match" | "High match" | "Moderate match" | "Lower match"; - tags: string[]; - safety: string; - reasons: string[]; -}; - -function diagnosisResultItem(match: DifferentialRecordMatch): Omit { - const { record, score, reasons } = match; - return { - id: record.slug, - kind: "diagnosis", - slug: record.slug, - title: record.title, - subtitle: record.clinicalHinge || record.subtitle, - href: `/differentials/diagnoses/${record.slug}`, - status: record.status, - score, - tags: [...record.currentPresentation.slice(0, 3), record.investigations[0]] - .filter((value): value is string => Boolean(value?.trim())) - .slice(0, 4), - safety: record.safetySnapshot.summary, - reasons, - }; -} - -function presentationResultItem( - match: DifferentialPresentationMatch, -): Omit { - const { workflow, score, reasons } = match; - return { - id: workflow.id, - kind: "presentation", - slug: workflow.id, - title: workflow.title, - subtitle: workflow.subtitle, - href: `/differentials/presentations/${workflow.id}`, - status: workflow.status, - score, - tags: workflow.safetySnapshot.tags.slice(0, 4), - safety: workflow.safetySnapshot.summary, - reasons, - }; -} - -/** Compose ranked diagnosis + presentation matches into one adaptive result - * list: when a presentation matches about as strongly as the best diagnosis - * it leads (followed by its candidate diagnoses in ranked order), otherwise - * results interleave purely by score. Deduped by id, capped at `limit`. */ -export function composeDifferentialSearchResults( - diagnoses: DifferentialRecordMatch[], - presentations: DifferentialPresentationMatch[], - limit = 8, -): DifferentialSearchResultItem[] { - const items: Array> = []; - const seen = new Set(); - const push = (item: Omit) => { - const key = `${item.kind}:${item.id}`; - if (seen.has(key)) return; - seen.add(key); - items.push(item); - }; - - const topPresentation = presentations[0]; - const topDiagnosisScore = diagnoses[0]?.score ?? 0; - const presentationLeads = - Boolean(topPresentation) && (topDiagnosisScore === 0 || topPresentation!.score >= topDiagnosisScore * 0.8); - - if (topPresentation && presentationLeads) { - push(presentationResultItem(topPresentation)); - const candidateSlugs = new Set(topPresentation.workflow.candidates.map((candidate) => candidate.slug)); - for (const match of diagnoses) { - if (candidateSlugs.has(match.record.slug)) push(diagnosisResultItem(match)); - } - for (const match of diagnoses) push(diagnosisResultItem(match)); - for (const match of presentations.slice(1)) push(presentationResultItem(match)); - } else { - const merged = [ - ...diagnoses.map((match) => ({ score: match.score, item: diagnosisResultItem(match) })), - ...presentations.map((match) => ({ score: match.score, item: presentationResultItem(match) })), - ].sort((left, right) => right.score - left.score); - for (const entry of merged) push(entry.item); - } - - return items.slice(0, limit).map((item, index) => ({ - ...item, - matchLabel: - index === 0 ? "Best match" : item.score >= 12 ? "High match" : item.score >= 6 ? "Moderate match" : "Lower match", - })); -} - /** Back-compat wrapper: empty query returns the full catalogue (the API route * relies on this), otherwise relevance-ranked results in ranked order. */ export function searchDifferentialRecords(query: string) { diff --git a/src/lib/document-detail-contract.ts b/src/lib/document-detail-contract.ts new file mode 100644 index 000000000..7d90e136f --- /dev/null +++ b/src/lib/document-detail-contract.ts @@ -0,0 +1,99 @@ +import type { ClinicalDocument } from "@/lib/types"; + +export type DocumentAssetScope = "document" | "window"; + +export type DocumentDetailPage = { + id: string; + page_number: number; + text: string; + ocr_used: boolean; + metadata?: Record | null; +}; + +export type DocumentDetailImage = { + id: string; + page_number: number | null; + caption: string; + image_type?: string | null; + searchable?: boolean | null; + clinical_relevance_score?: number | null; + labels?: string[] | null; + source_kind?: string | null; + tableLabel?: string | null; + tableTitle?: string | null; + tableRole?: string | null; + tableTextSnippet?: string | null; + clinicalUseClass?: string | null; + clinicalUseReason?: string | null; + accessibleTableMarkdown?: string | null; + tableRows?: string[][] | null; + tableColumns?: string[] | null; +}; + +export type DocumentDetailTableFact = { + id: string; + document_id: string; + source_image_id: string | null; + page_number: number | null; + table_title: string | null; + row_label: string | null; + clinical_parameter: string | null; + threshold_value: string | null; + action: string | null; + metadata?: Record | null; +}; + +export type DocumentDetailChunk = { + id: string; + page_number: number | null; + chunk_index: number; + section_heading: string | null; + content: string; + image_ids: string[]; + metadata?: Record | null; +}; + +export type DocumentDetailIndexHealth = { + extractionQuality?: string | null; + indexedAt?: string | null; + indexVersion?: string | null; + warnings?: unknown; +}; + +export type DocumentPageWindow = { + from: number; + to: number; + limit: number; + total: number | null; + hasBefore: boolean; + hasAfter: boolean; +}; + +export type DocumentChunkWindow = { + offset: number; + limit: number; + total: number | null; + hasBefore: boolean; + hasAfter: boolean; + selectedChunkId: string | null; +}; + +export type DocumentDetailPayload = { + document: ClinicalDocument; + pages: DocumentDetailPage[]; + images: DocumentDetailImage[]; + tableFacts: DocumentDetailTableFact[]; + chunks: DocumentDetailChunk[]; + indexHealth?: DocumentDetailIndexHealth; + demoMode: boolean; + assetScope: DocumentAssetScope; + window: { + requestedPage: number; + effectivePage: number; + selectedChunkId: string | null; + pages: DocumentPageWindow; + chunks: DocumentChunkWindow; + }; + pageWindow: DocumentPageWindow; + chunkWindow: DocumentChunkWindow; +}; diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts new file mode 100644 index 000000000..a1725059e --- /dev/null +++ b/src/lib/document-detail.ts @@ -0,0 +1,528 @@ +import "server-only"; + +import { z } from "zod"; +import { getDemoDocumentPayload } from "@/lib/demo-data"; +import { isDemoMode } from "@/lib/env"; +import { PublicApiError } from "@/lib/http"; +import { callerOwnsDocumentRow, enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; +import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { AuthenticationError } from "@/lib/supabase/auth"; +import { parseRouteParams } from "@/lib/validation/params"; +import { optionalQueryString, queryInteger } from "@/lib/validation/query"; +import type { ApiRateLimitResult } from "@/lib/api-rate-limit"; +import type { ClinicalDocument } from "@/lib/types"; +import type { + DocumentAssetScope, + DocumentChunkWindow, + DocumentDetailChunk, + DocumentDetailImage, + DocumentDetailPage, + DocumentDetailPayload, + DocumentDetailTableFact, + DocumentPageWindow, +} from "@/lib/document-detail-contract"; + +const defaultPageWindow = 9; +const maxPageWindow = 40; +const defaultChunkWindow = 16; +const maxChunkWindow = 80; +const selectedChunkNeighborCount = 3; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const documentDetailProjection = + "id,owner_id,title,description,file_name,file_type,file_size,storage_path,content_hash,source_path,import_batch_id,status,page_count,chunk_count,image_count,error_message,metadata,created_at,updated_at" as const; +const tableFactDetailProjection = + "id,document_id,source_image_id,page_number,table_title,row_label,clinical_parameter,threshold_value,action,metadata" as const; +const documentLabelDetailProjection = + "id,document_id,owner_id,label,label_type,source,confidence,metadata,created_at,updated_at" as const; +const documentSummaryDetailProjection = + "id,document_id,owner_id,summary,clinical_specifics,source_chunk_ids,source_image_ids,model,generated_at,created_at,updated_at" as const; + +const documentRouteParamsSchema = z.object({ + id: z.string().uuid(), +}); + +export const documentDetailQuerySchema = z.object({ + chunk: optionalQueryString({ maxLength: 80 }), + page: queryInteger({ fallback: 1, min: 1, max: 1_000_000 }), + pageLimit: queryInteger({ fallback: defaultPageWindow, min: 1, max: maxPageWindow }), + chunkLimit: queryInteger({ fallback: defaultChunkWindow, min: 1, max: maxChunkWindow }), + chunkOffset: queryInteger({ fallback: 0, min: 0, max: 1_000_000 }), + assetScope: z.enum(["document", "window"]).default("document"), +}); + +export type DocumentDetailQuery = { + chunk?: string; + page: number; + pageLimit: number; + chunkLimit: number; + chunkOffset: number; + assetScope: "document" | "window"; +}; + +export class DocumentDetailRateLimitError extends Error { + constructor(readonly rateLimit: ApiRateLimitResult) { + super("Document requests are rate limited. Try again shortly."); + this.name = "DocumentDetailRateLimitError"; + } +} + +function pageWindowAround(pageNumber: number, limit: number, maxPage?: number | null) { + const half = Math.floor(limit / 2); + const max = Math.max(1, maxPage ?? Number.MAX_SAFE_INTEGER); + const from = Math.max(1, Math.min(pageNumber - half, Math.max(1, max - limit + 1))); + const to = Math.min(max, from + limit - 1); + return { from, to }; +} + +function safeMetadata(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function metadataText(metadata: Record, key: string) { + const value = metadata[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function compactTableText(value: string | null, limit = 500) { + if (!value) return null; + const compact = value.replace(/\s+/g, " ").trim(); + if (!compact) return null; + return compact.length > limit ? `${compact.slice(0, limit - 3).trim()}...` : compact; +} + +function metadataStringArrayRows(metadata: Record, key: string) { + const value = metadata[key]; + if (!Array.isArray(value)) return null; + const rows = value + .filter((row): row is unknown[] => Array.isArray(row)) + .map((row) => row.map((cell) => String(cell ?? "").trim())); + return rows.length ? rows : null; +} + +function metadataStringArray(metadata: Record, key: string) { + const value = metadata[key]; + if (!Array.isArray(value)) return null; + const items = value.map((item) => String(item ?? "").trim()).filter(Boolean); + return items.length ? items : null; +} + +function withImageTableMetadata(image: T) { + const metadata = safeMetadata(image.metadata); + const rawTableText = metadataText(metadata, "table_text"); + const tableText = rawTableText ?? metadataText(metadata, "table_text_snippet"); + const publicImage = { ...image }; + delete publicImage.metadata; + return { + ...publicImage, + tableLabel: metadataText(metadata, "table_label"), + tableTitle: metadataText(metadata, "table_title"), + tableRole: metadataText(metadata, "table_role"), + tableTextSnippet: compactTableText(tableText), + clinicalUseClass: metadataText(metadata, "clinical_use_class"), + clinicalUseReason: metadataText(metadata, "clinical_use_reason"), + accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText, + tableRows: metadataStringArrayRows(metadata, "table_rows"), + tableColumns: metadataStringArray(metadata, "table_columns"), + }; +} + +function withoutMetadata(row: T) { + const projected = { ...row }; + delete projected.metadata; + return projected; +} + +function withTableFactReviewMetadata(fact: T) { + const metadata = safeMetadata(fact.metadata); + const projected = withoutMetadata(fact); + const reviewClass = metadataText(metadata, "review_class"); + return reviewClass ? { ...projected, metadata: { review_class: reviewClass } } : projected; +} + +function isHiddenDocumentLabel(label: { metadata?: unknown }) { + const metadata = safeMetadata(label.metadata); + return metadata.hidden === true || metadata.review_status === "hidden" || metadata.label_review_status === "hidden"; +} + +function withDocumentLabelReviewMetadata(label: T) { + const metadata = safeMetadata(label.metadata); + const projected = withoutMetadata(label); + const reviewStatus = metadataText(metadata, "review_status"); + const legacyReviewStatus = metadataText(metadata, "label_review_status"); + const reviewMetadata = { + ...(reviewStatus ? { review_status: reviewStatus } : {}), + ...(legacyReviewStatus ? { label_review_status: legacyReviewStatus } : {}), + ...(metadata.hidden === true ? { hidden: true } : {}), + }; + return Object.keys(reviewMetadata).length > 0 ? { ...projected, metadata: reviewMetadata } : projected; +} + +function committedRows(document: { metadata?: unknown }, rows: T[]) { + const committedGeneration = committedIndexGeneration(document.metadata); + return rows.filter((row) => isCommittedGenerationMetadata({ rowMetadata: row.metadata, committedGeneration })); +} + +function selectedImageIds(selectedChunk: DocumentDetailChunk | null) { + return Array.from( + new Set( + (selectedChunk?.image_ids ?? []).filter((id): id is string => typeof id === "string" && uuidPattern.test(id)), + ), + ); +} + +function imageWindowFilter(pageWindow: { from: number; to: number }, imageIds: string[]) { + const filters = [ + `and(image_type.neq.logo_decorative,or(searchable.eq.true,source_kind.eq.table_crop),page_number.gte.${pageWindow.from},page_number.lte.${pageWindow.to})`, + ]; + if (imageIds.length > 0) filters.push(`id.in.(${imageIds.join(",")})`); + return filters.join(","); +} + +function tableFactWindowFilter(pageWindow: { from: number; to: number }, imageIds: string[]) { + const filters = ["page_number.is.null", `and(page_number.gte.${pageWindow.from},page_number.lte.${pageWindow.to})`]; + if (imageIds.length > 0) filters.push(`source_image_id.in.(${imageIds.join(",")})`); + return filters.join(","); +} + +function windowMetadata(args: { + requestedPage: number; + effectivePage: number; + selectedChunk: DocumentDetailChunk | null; + pageWindow: { from: number; to: number }; + pageLimit: number; + pageTotal: number | null; + chunkRangeStart: number; + chunkRangeEnd: number; + chunkLimit: number; + chunkTotal: number | null; +}) { + const pageWindow: DocumentPageWindow = { + from: args.pageWindow.from, + to: args.pageWindow.to, + limit: args.pageLimit, + total: args.pageTotal, + hasBefore: args.pageWindow.from > 1, + hasAfter: Boolean(args.pageTotal && args.pageWindow.to < args.pageTotal), + }; + const chunkWindow: DocumentChunkWindow = { + offset: args.chunkRangeStart, + limit: args.selectedChunk ? args.chunkRangeEnd - args.chunkRangeStart + 1 : args.chunkLimit, + total: args.chunkTotal, + hasBefore: args.chunkRangeStart > 0, + hasAfter: Boolean(args.chunkTotal && args.chunkRangeEnd + 1 < args.chunkTotal), + selectedChunkId: args.selectedChunk?.id ?? null, + }; + return { + pageWindow, + chunkWindow, + window: { + requestedPage: args.requestedPage, + effectivePage: args.effectivePage, + selectedChunkId: args.selectedChunk?.id ?? null, + pages: pageWindow, + chunks: chunkWindow, + }, + }; +} + +function filterDemoAssets( + rows: T[], + assetScope: DocumentAssetScope, + pageWindow: { from: number; to: number }, + preserve: (row: T) => boolean, + includeGlobal: boolean, +) { + if (assetScope === "document") return rows; + return rows.filter( + (row) => + (includeGlobal && row.page_number === null) || + (row.page_number !== null && row.page_number >= pageWindow.from && row.page_number <= pageWindow.to) || + preserve(row), + ); +} + +function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): DocumentDetailPayload { + const rawPayload = getDemoDocumentPayload(rawId); + if (!rawPayload) throw new PublicApiError("Demo document not found.", 404); + + const payload = rawPayload as unknown as { + document: ClinicalDocument; + pages: DocumentDetailPage[]; + images: DocumentDetailImage[]; + chunks: DocumentDetailChunk[]; + tableFacts?: DocumentDetailTableFact[]; + indexHealth?: DocumentDetailPayload["indexHealth"]; + }; + const allChunks = payload.chunks ?? []; + const selectedChunk = query.chunk ? (allChunks.find((chunk) => chunk.id === query.chunk) ?? null) : null; + const requestedPage = Math.min(query.page, Math.max(1, payload.document.page_count ?? 1)); + const effectivePage = selectedChunk?.page_number ?? requestedPage; + const pageRange = pageWindowAround(effectivePage, query.pageLimit, payload.document.page_count); + const chunkRangeStart = selectedChunk + ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount) + : query.chunkOffset; + const chunkRangeEnd = selectedChunk + ? selectedChunk.chunk_index + selectedChunkNeighborCount + : query.chunkOffset + query.chunkLimit - 1; + const chunks = allChunks.filter( + (chunk) => chunk.chunk_index >= chunkRangeStart && chunk.chunk_index <= chunkRangeEnd, + ); + const preservedImageIds = new Set(selectedChunk?.image_ids ?? []); + const images = filterDemoAssets( + payload.images ?? [], + query.assetScope, + pageRange, + (image) => preservedImageIds.has(image.id), + false, + ); + const tableFacts = filterDemoAssets( + payload.tableFacts ?? [], + query.assetScope, + pageRange, + (fact) => Boolean(fact.source_image_id && preservedImageIds.has(fact.source_image_id)), + true, + ); + const metadata = windowMetadata({ + requestedPage, + effectivePage, + selectedChunk, + pageWindow: pageRange, + pageLimit: query.pageLimit, + pageTotal: payload.document.page_count ?? null, + chunkRangeStart, + chunkRangeEnd, + chunkLimit: query.chunkLimit, + chunkTotal: payload.document.chunk_count ?? allChunks.length, + }); + + return { + document: payload.document, + pages: (payload.pages ?? []).filter( + (page) => page.page_number >= pageRange.from && page.page_number <= pageRange.to, + ), + images, + tableFacts, + chunks, + ...(payload.indexHealth ? { indexHealth: payload.indexHealth } : {}), + demoMode: true, + assetScope: query.assetScope, + ...metadata, + }; +} + +function omitPublicInternalFields(row: Record) { + const internalKeys = new Set([ + "owner_id", + "storage_path", + "content_hash", + "source_path", + "import_batch_id", + "error_message", + "metadata", + "source_chunk_ids", + "source_image_ids", + "model", + ]); + return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key))); +} + +/** + * Loads the minimal authorized document-detail DTO shared by the API route and + * the Server Component page. The document authorization check is completed + * before any child table is queried. + */ +export async function loadAuthorizedDocumentDetail(args: { + request: Request; + rawId: string; + query: DocumentDetailQuery; +}): Promise { + const { rawId, query } = args; + args.request.signal.throwIfAborted(); + if (isDemoMode()) return loadDemoDocumentDetail(rawId, query); + + const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); + const supabase = createAdminClient(); + const { access, rateLimit } = await enforceDocumentReadRateLimit(args.request, supabase); + if (rateLimit.limited) throw new DocumentDetailRateLimitError(rateLimit); + args.request.signal.throwIfAborted(); + + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select(documentDetailProjection).eq("id", id), + access.ownerId, + ) + .abortSignal(args.request.signal) + .maybeSingle(); + if (documentError) throw new Error(documentError.message); + if (!document) throw new PublicApiError("Document not found.", 404); + args.request.signal.throwIfAborted(); + + const isOwner = callerOwnsDocumentRow(document, access.ownerId); + let selectedChunk: DocumentDetailChunk | null = null; + if (query.chunk) { + const { data, error } = await supabase + .from("document_chunks") + .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata") + .eq("document_id", id) + .eq("id", query.chunk) + .abortSignal(args.request.signal) + .maybeSingle(); + if (error) throw new Error(error.message); + if (data && committedRows(document, [data]).length > 0) { + selectedChunk = { + ...data, + image_ids: Array.isArray(data.image_ids) ? data.image_ids : [], + } as DocumentDetailChunk; + } + args.request.signal.throwIfAborted(); + } + + const requestedPage = Math.min(query.page, Math.max(1, document.page_count ?? 1)); + const effectivePage = selectedChunk?.page_number ?? requestedPage; + const pageRange = pageWindowAround(effectivePage, query.pageLimit, document.page_count); + const chunkRangeStart = selectedChunk + ? Math.max(0, selectedChunk.chunk_index - selectedChunkNeighborCount) + : query.chunkOffset; + const chunkRangeEnd = selectedChunk + ? selectedChunk.chunk_index + selectedChunkNeighborCount + : query.chunkOffset + query.chunkLimit - 1; + const preservedImageIds = selectedImageIds(selectedChunk); + + const pagesRequest = supabase + .from("document_pages") + .select("id,page_number,text,ocr_used,metadata") + .eq("document_id", id) + .gte("page_number", pageRange.from) + .lte("page_number", pageRange.to) + .order("page_number", { ascending: true }) + .abortSignal(args.request.signal); + + const chunkQuery = supabase + .from("document_chunks") + .select("id,page_number,chunk_index,section_heading,content,image_ids,metadata") + .eq("document_id", id) + .order("chunk_index", { ascending: true }); + const chunksRequest = ( + selectedChunk + ? chunkQuery.gte("chunk_index", chunkRangeStart).lte("chunk_index", chunkRangeEnd) + : chunkQuery.range(chunkRangeStart, chunkRangeEnd) + ).abortSignal(args.request.signal); + + let imagesRequest = supabase + .from("document_images") + .select( + "id,page_number,storage_path,caption,bbox,mime_type,image_type,searchable,clinical_relevance_score,source_kind,width,height,labels,metadata", + ) + .eq("document_id", id); + if (query.assetScope === "window") { + imagesRequest = imagesRequest.or(imageWindowFilter(pageRange, preservedImageIds)); + } else { + imagesRequest = imagesRequest + .neq("image_type", "logo_decorative") + .or("searchable.eq.true,source_kind.eq.table_crop"); + } + const imagesPending = imagesRequest.order("page_number", { ascending: true }).abortSignal(args.request.signal); + + let tableFactsRequest = supabase.from("document_table_facts").select(tableFactDetailProjection).eq("document_id", id); + if (query.assetScope === "window") { + tableFactsRequest = tableFactsRequest.or(tableFactWindowFilter(pageRange, preservedImageIds)); + } + const tableFactsPending = tableFactsRequest + .order("page_number", { ascending: true }) + .limit(200) + .abortSignal(args.request.signal); + + const labelsRequest = supabase + .from("document_labels") + .select(documentLabelDetailProjection) + .eq("document_id", id) + .order("confidence", { ascending: false }) + .abortSignal(args.request.signal); + const summaryRequest = supabase + .from("document_summaries") + .select(documentSummaryDetailProjection) + .eq("document_id", id) + .abortSignal(args.request.signal) + .maybeSingle(); + + const [pagesResult, chunksResult, imagesResult, tableFactsResult, labelsResult, summaryResult] = await Promise.all([ + pagesRequest, + chunksRequest, + imagesPending, + tableFactsPending, + labelsRequest, + summaryRequest, + ]); + + for (const result of [pagesResult, chunksResult, imagesResult, tableFactsResult, labelsResult, summaryResult]) { + if (result.error) throw new Error(result.error.message); + } + + const publicRows = >(rows: T[]) => + isOwner ? rows : rows.map(omitPublicInternalFields); + const labels = (labelsResult.data ?? []) + .filter((label) => !isHiddenDocumentLabel(label)) + .map(withDocumentLabelReviewMetadata); + const responseDocument = isOwner + ? document + : omitPublicInternalFields(document as unknown as Record); + const documentMetadata = safeMetadata(document.metadata); + const metadata = windowMetadata({ + requestedPage, + effectivePage, + selectedChunk, + pageWindow: pageRange, + pageLimit: query.pageLimit, + pageTotal: document.page_count ?? null, + chunkRangeStart, + chunkRangeEnd, + chunkLimit: query.chunkLimit, + chunkTotal: document.chunk_count ?? null, + }); + + return { + document: { + ...responseDocument, + labels: publicRows(labels as Record[]), + summary: + isOwner || !summaryResult.data + ? (summaryResult.data ?? null) + : omitPublicInternalFields(summaryResult.data as Record), + } as unknown as ClinicalDocument, + pages: publicRows( + committedRows(document, pagesResult.data ?? []).map(withoutMetadata) as Record[], + ) as DocumentDetailPage[], + images: publicRows( + committedRows(document, imagesResult.data ?? []).map(withImageTableMetadata) as Record[], + ) as DocumentDetailImage[], + tableFacts: publicRows( + committedRows(document, tableFactsResult.data ?? []).map(withTableFactReviewMetadata) as Record< + string, + unknown + >[], + ) as DocumentDetailTableFact[], + chunks: publicRows( + committedRows(document, chunksResult.data ?? []).map(withoutMetadata) as Record[], + ) as DocumentDetailChunk[], + ...(isOwner + ? { + indexHealth: { + extractionQuality: metadataText(documentMetadata, "extraction_quality"), + indexedAt: metadataText(documentMetadata, "indexed_at"), + indexVersion: metadataText(documentMetadata, "rag_indexing_version"), + warnings: documentMetadata.extraction_warnings ?? [], + }, + } + : {}), + demoMode: false, + assetScope: query.assetScope, + ...metadata, + }; +} + +export function sanitizeDocumentDetailError(error: unknown) { + if (error instanceof DocumentDetailRateLimitError) return error.message; + if (error instanceof AuthenticationError) return "Sign in to open private source documents."; + if (error instanceof PublicApiError) return error.message; + return "Document could not be loaded."; +} diff --git a/src/lib/document-enrichment.ts b/src/lib/document-enrichment.ts index 3f7089839..8982a5229 100644 --- a/src/lib/document-enrichment.ts +++ b/src/lib/document-enrichment.ts @@ -735,26 +735,32 @@ export async function fetchRelatedDocumentMetadata(args: { ownerId?: string; accessScope?: RetrievalAccessScope; documentIds: string[]; + signal?: AbortSignal; }) { const accessScope = retrievalAccessScopeForArgs(args); - const versionedResult = await args.supabase.rpc("get_related_document_metadata_v2", { + const versionedQuery = args.supabase.rpc("get_related_document_metadata_v2", { document_ids: args.documentIds, ...retrievalRpcScopeArgs(accessScope), }); + const versionedResult = await (args.signal ? versionedQuery.abortSignal(args.signal) : versionedQuery); let rpcData = versionedResult?.data; let rpcError = versionedResult?.error; if (!versionedResult || isMissingRetrievalRpcError(versionedResult.error)) { const ownerFilter = accessScope.ownerId ?? PUBLIC_OWNER_FILTER_SENTINEL; - const ownerResult = await args.supabase.rpc("get_related_document_metadata", { + const ownerQuery = args.supabase.rpc("get_related_document_metadata", { document_ids: args.documentIds, owner_filter: ownerFilter, }); + const ownerResult = await (args.signal ? ownerQuery.abortSignal(args.signal) : ownerQuery); const publicResult = accessScope.ownerId && accessScope.includePublic - ? await args.supabase.rpc("get_related_document_metadata", { - document_ids: args.documentIds, - owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, - }) + ? await (() => { + const query = args.supabase.rpc("get_related_document_metadata", { + document_ids: args.documentIds, + owner_filter: PUBLIC_OWNER_FILTER_SENTINEL, + }); + return args.signal ? query.abortSignal(args.signal) : query; + })() : { data: [], error: null }; rpcError = ownerResult.error ?? publicResult.error; const merged = new Map(); @@ -792,6 +798,11 @@ export async function fetchRelatedDocumentMetadata(args: { summariesQuery = summariesQuery.is("owner_id", null); } + if (args.signal) { + labelsQuery = labelsQuery.abortSignal(args.signal); + summariesQuery = summariesQuery.abortSignal(args.signal); + } + const [labelsResult, summariesResult] = await Promise.all([labelsQuery, summariesQuery]); const labels = (labelsResult.data ?? []) as DocumentLabel[]; const summaries = (summariesResult.data ?? []) as Pick[]; @@ -819,6 +830,8 @@ export async function fetchRelatedDocuments(args: { query: string; results: SearchResult[]; limit?: number; + includeVisualCounts?: boolean; + signal?: AbortSignal; }) { const grouped = new Map< string, @@ -858,14 +871,19 @@ export async function fetchRelatedDocuments(args: { const documentIds = Array.from(grouped.keys()); if (documentIds.length === 0) return []; + const visualCountsPromise = + args.includeVisualCounts === false + ? Promise.resolve(new Map()) + : fetchDocumentVisualCounts(args.supabase, documentIds, args.signal); const [metadataRows, visualCounts] = await Promise.all([ fetchRelatedDocumentMetadata({ supabase: args.supabase, ownerId: args.ownerId, accessScope: args.accessScope, documentIds, + signal: args.signal, }), - fetchDocumentVisualCounts(args.supabase, documentIds), + visualCountsPromise, ]); const labelsByDocument = new Map(); const summariesByDocument = new Map(); @@ -903,16 +921,18 @@ export async function fetchRelatedDocuments(args: { .slice(0, args.limit ?? 6); } -export async function fetchDocumentVisualCounts(supabase: SupabaseClient, documentIds: string[]) { +export async function fetchDocumentVisualCounts(supabase: SupabaseClient, documentIds: string[], signal?: AbortSignal) { const counts = new Map(); const uniqueIds = Array.from(new Set(documentIds)); if (uniqueIds.length === 0) return counts; - const { data, error } = await supabase + let query = supabase .from("document_images") .select("document_id,source_kind,searchable,image_type,clinical_relevance_score,metadata") .in("document_id", uniqueIds) .neq("image_type", "logo_decorative"); + if (signal) query = query.abortSignal(signal); + const { data, error } = await query; if (error) throw new Error(error.message); diff --git a/src/lib/fixture-response-cache.ts b/src/lib/fixture-response-cache.ts new file mode 100644 index 000000000..920e8512e --- /dev/null +++ b/src/lib/fixture-response-cache.ts @@ -0,0 +1,45 @@ +const PUBLIC_FIXTURE_CACHE_CONTROL = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"; +const PRIVATE_CACHE_CONTROL = "private, no-store"; + +function hasAuthenticationSignal(request: Request) { + if (request.headers.get("authorization")?.trim()) return true; + + const cookieHeader = request.headers.get("cookie"); + if (!cookieHeader) return false; + + return cookieHeader.split(";").some((part) => { + const separator = part.indexOf("="); + const name = (separator === -1 ? part : part.slice(0, separator)).trim().toLowerCase(); + return name.startsWith("sb-"); + }); +} + +function addVary(headers: Headers, names: string[]) { + const values = (headers.get("Vary") ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + const seen = new Set(values.map((value) => value.toLowerCase())); + + for (const name of names) { + if (!seen.has(name.toLowerCase())) { + values.push(name); + seen.add(name.toLowerCase()); + } + } + + headers.set("Vary", values.join(", ")); +} + +export function fixtureResponseHeaders( + request: Request | undefined, + options: { fixture?: boolean; headers?: HeadersInit } = {}, +) { + const headers = new Headers(options.headers); + const isPublicFixture = Boolean(request && options.fixture && !hasAuthenticationSignal(request)); + + headers.set("Cache-Control", isPublicFixture ? PUBLIC_FIXTURE_CACHE_CONTROL : PRIVATE_CACHE_CONTROL); + if (request) addVary(headers, ["Cookie", "Authorization"]); + + return headers; +} diff --git a/src/lib/form-catalog.ts b/src/lib/form-catalog.ts index 6b66a654b..852e4127f 100644 --- a/src/lib/form-catalog.ts +++ b/src/lib/form-catalog.ts @@ -1,62 +1,16 @@ import formsCatalog from "../../data/forms-catalog.json"; import formsPdfManifest from "../../data/forms-pdf-manifest.json"; +import type { FormAvailability, FormCatalogDetails } from "@/lib/form-ranker"; import type { ServiceChipTone, ServiceRecord } from "@/lib/services"; +export { formCatalogDetails } from "@/lib/form-ranker"; +export type { FormAvailability, FormCatalogDetails } from "@/lib/form-ranker"; + export const officialFormsRegisterUrl = "https://www.chiefpsychiatrist.wa.gov.au/laws-and-rights/legislation/mental-health-act-2014-forms/"; export const officialFormsReviewedDate = "17 July 2026"; -export type FormAvailability = "downloadable" | "unavailable" | "contact_ocp"; - -export type FormCatalogDetails = { - id: string; - form: string; - name: string; - category: string; - purpose: string; - maker: string; - involved: string; - threshold: string; - clock: string; - destination: string; - authorises: string; - doesNotAuthorise: string; - before: string[]; - parallel: string[]; - after: string[]; - copies: string; - documentationStem: string; - traps: string[]; - safetyPearl: string; - sourceNote: string; - aliases: string[]; - searchTerms: string[]; - riskLevel: "high" | "medium" | "low"; - indexedClock?: string; - indexedTerms?: string[]; - legalNote: string; - practicePearls: string[]; - preUseChecks: string[]; - sourceFacts?: { - documentTitle?: string; - fileName?: string; - pages?: number; - timings?: string[]; - sectionCue?: string; - indexedAt?: string; - }; - availability: FormAvailability; - officialPdfUrl?: string; - officialRegisterUrl: string; - localPdfPath?: string; - localPdfSha256?: string; - localPdfBytes?: number; - officialPdfPasswordProtected?: boolean; - officialTitleCheckedAt: string; - archiveGeneratedAt?: string; -}; - type OfficialForm = { code: string; title: string; @@ -473,11 +427,3 @@ export function loadFormCatalogDetails(): FormCatalogDetails[] { export function mapFormCatalogToRecords(): ServiceRecord[] { return loadFormCatalogDetails().map(toFormRecord); } - -export function formCatalogDetails(record: ServiceRecord): FormCatalogDetails | null { - const payload = record.catalogPayload; - if (!payload || typeof payload !== "object") return null; - const candidate = payload as Partial; - if (!candidate.form || !candidate.name || !candidate.category || !candidate.availability) return null; - return candidate as FormCatalogDetails; -} diff --git a/src/lib/form-ranker.ts b/src/lib/form-ranker.ts new file mode 100644 index 000000000..0100e53aa --- /dev/null +++ b/src/lib/form-ranker.ts @@ -0,0 +1,171 @@ +import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; +import type { ServiceRecord, ServiceSearchMatch } from "@/lib/service-ranker"; + +export type FormRecord = ServiceRecord; +export type FormSearchMatch = ServiceSearchMatch; + +export type FormAvailability = "downloadable" | "unavailable" | "contact_ocp"; + +export type FormCatalogDetails = { + id: string; + form: string; + name: string; + category: string; + purpose: string; + maker: string; + involved: string; + threshold: string; + clock: string; + destination: string; + authorises: string; + doesNotAuthorise: string; + before: string[]; + parallel: string[]; + after: string[]; + copies: string; + documentationStem: string; + traps: string[]; + safetyPearl: string; + sourceNote: string; + aliases: string[]; + searchTerms: string[]; + riskLevel: "high" | "medium" | "low"; + indexedClock?: string; + indexedTerms?: string[]; + legalNote: string; + practicePearls: string[]; + preUseChecks: string[]; + sourceFacts?: { + documentTitle?: string; + fileName?: string; + pages?: number; + timings?: string[]; + sectionCue?: string; + indexedAt?: string; + }; + availability: FormAvailability; + officialPdfUrl?: string; + officialRegisterUrl: string; + localPdfPath?: string; + localPdfSha256?: string; + localPdfBytes?: number; + officialPdfPasswordProtected?: boolean; + officialTitleCheckedAt: string; + archiveGeneratedAt?: string; +}; + +export function formCatalogDetails(record: FormRecord): FormCatalogDetails | null { + const payload = record.catalogPayload; + if (!payload || typeof payload !== "object") return null; + const candidate = payload as Partial; + if (!candidate.form || !candidate.name || !candidate.category || !candidate.availability) return null; + return candidate as FormCatalogDetails; +} + +export function formRecordSearchText(form: FormRecord) { + const details = formCatalogDetails(form); + const values = [ + form.title, + form.slug, + form.subtitle, + form.route, + form.eligibility, + form.cost, + form.referral, + form.location, + form.bestUse, + form.catalogueLabel, + form.navigatorQuery, + form.primaryContact?.value, + form.primaryContact?.detail, + form.source?.label, + form.source?.status, + form.source?.reviewed, + ...(form.tags ?? []), + ...(form.catchments ?? []), + ...(form.statusChips ?? []).flatMap((chip) => [chip.label]), + ...(form.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail]), + ...(form.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]), + ...(form.referralInfo ?? []).flatMap((row) => [row.label, row.value]), + ...(form.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]), + ...(form.verification?.notes ?? []), + details?.form, + details?.category, + details?.purpose, + details?.maker, + details?.threshold, + details?.clock, + details?.authorises, + details?.doesNotAuthorise, + ...(details?.aliases ?? []), + ...(details?.searchTerms ?? []), + ...(details?.indexedTerms ?? []), + "form", + "forms", + "checklist", + "assessment", + "transfer", + "template", + ].filter((value): value is string => Boolean(value?.trim())); + + return normalizeSearchText(values.join(" ")); +} + +export function formNavigatorQuery(form: FormRecord) { + return ( + [form.navigatorQuery, form.title, form.primaryContact?.value, form.subtitle, form.slug].find((value) => + value?.trim(), + ) ?? form.slug + ); +} + +export function rankFormRecords( + records: FormRecord[], + query: string, + limit = records.length, + // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane. + expansions: string[] = [], +): FormSearchMatch[] { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return []; + // A bare "service(s)" query belongs to the services catalogue, not forms. + if (/^services?$/.test(normalizedQuery)) return []; + + return rankCatalogRecords(records, query, { + fields: [ + { id: "title", weight: 6, text: (form) => normalizeSearchText(`${form.title} ${form.slug}`) }, + { id: "contact", weight: 5, text: (form) => normalizeSearchText(form.primaryContact?.value ?? "") }, + ], + fullText: formRecordSearchText, + contentWeight: 2, + compactBonus: 5, + phraseBonus: 4, + broadTerms: [ + "form", + "forms", + "checklist", + "transport", + "transfer", + "extension", + "detention", + "movement", + "examination", + "template", + "assessment", + ], + broadBonus: 1, + expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined, + limit, + // No tieBreak: forms historically tie-break by catalogue (input) order, which is the + // generic ranker's default. + }).map(({ record, score, signals }) => ({ + service: record, + score, + reasons: [ + signals.fields.title ? "title" : "", + signals.fields.contact || signals.compact ? "contact" : "", + signals.content ? "record fields" : "", + signals.broad ? "psychiatry forms catalogue" : "", + ].filter(Boolean), + })); +} diff --git a/src/lib/forms.ts b/src/lib/forms.ts index 6e6c37e6b..1bab4e3c0 100644 --- a/src/lib/forms.ts +++ b/src/lib/forms.ts @@ -1,61 +1,10 @@ -import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; -import { formCatalogDetails, mapFormCatalogToRecords } from "@/lib/form-catalog"; -import type { ServiceRecord, ServiceSearchMatch } from "@/lib/services"; +import { mapFormCatalogToRecords } from "@/lib/form-catalog"; +import { rankFormRecords, type FormRecord, type FormSearchMatch } from "@/lib/form-ranker"; -export type FormRecord = ServiceRecord; -export type FormSearchMatch = ServiceSearchMatch; +export * from "@/lib/form-ranker"; export const formRecords: FormRecord[] = mapFormCatalogToRecords(); -export function formRecordSearchText(form: FormRecord) { - const details = formCatalogDetails(form); - const values = [ - form.title, - form.slug, - form.subtitle, - form.route, - form.eligibility, - form.cost, - form.referral, - form.location, - form.bestUse, - form.catalogueLabel, - form.navigatorQuery, - form.primaryContact?.value, - form.primaryContact?.detail, - form.source?.label, - form.source?.status, - form.source?.reviewed, - ...(form.tags ?? []), - ...(form.catchments ?? []), - ...(form.statusChips ?? []).flatMap((chip) => [chip.label]), - ...(form.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail]), - ...(form.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]), - ...(form.referralInfo ?? []).flatMap((row) => [row.label, row.value]), - ...(form.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]), - ...(form.verification?.notes ?? []), - details?.form, - details?.category, - details?.purpose, - details?.maker, - details?.threshold, - details?.clock, - details?.authorises, - details?.doesNotAuthorise, - ...(details?.aliases ?? []), - ...(details?.searchTerms ?? []), - ...(details?.indexedTerms ?? []), - "form", - "forms", - "checklist", - "assessment", - "transfer", - "template", - ].filter((value): value is string => Boolean(value?.trim())); - - return normalizeSearchText(values.join(" ")); -} - function normalizeSlug(value: string) { return value.trim().toLowerCase(); } @@ -73,65 +22,6 @@ export function defaultFormSlug() { return formRecords[0]?.slug ?? null; } -export function formNavigatorQuery(form: FormRecord) { - return ( - [form.navigatorQuery, form.title, form.primaryContact?.value, form.subtitle, form.slug].find((value) => - value?.trim(), - ) ?? form.slug - ); -} - -export function rankFormRecords( - records: FormRecord[], - query: string, - limit = records.length, - // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane. - expansions: string[] = [], -): FormSearchMatch[] { - const normalizedQuery = normalizeSearchText(query); - if (!normalizedQuery) return []; - // A bare "service(s)" query belongs to the services catalogue, not forms. - if (/^services?$/.test(normalizedQuery)) return []; - - return rankCatalogRecords(records, query, { - fields: [ - { id: "title", weight: 6, text: (form) => normalizeSearchText(`${form.title} ${form.slug}`) }, - { id: "contact", weight: 5, text: (form) => normalizeSearchText(form.primaryContact?.value ?? "") }, - ], - fullText: formRecordSearchText, - contentWeight: 2, - compactBonus: 5, - phraseBonus: 4, - broadTerms: [ - "form", - "forms", - "checklist", - "transport", - "transfer", - "extension", - "detention", - "movement", - "examination", - "template", - "assessment", - ], - broadBonus: 1, - expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined, - limit, - // No tieBreak: forms historically tie-break by catalogue (input) order, which is the - // generic ranker's default. - }).map(({ record, score, signals }) => ({ - service: record, - score, - reasons: [ - signals.fields.title ? "title" : "", - signals.fields.contact || signals.compact ? "contact" : "", - signals.content ? "record fields" : "", - signals.broad ? "psychiatry forms catalogue" : "", - ].filter(Boolean), - })); -} - export function searchFormRecords(query: string, limit = formRecords.length): FormSearchMatch[] { return rankFormRecords(formRecords, query, limit); } diff --git a/src/lib/http.ts b/src/lib/http.ts index 652981f52..82ee6613c 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -68,7 +68,7 @@ export function jsonError(error: unknown, status = 500) { code, ...(requestId ? { requestId } : {}), }, - { status: responseStatus }, + { status: responseStatus, headers: { "Cache-Control": "private, no-store" } }, ); } diff --git a/src/lib/medication-seed.ts b/src/lib/medication-seed.ts index fcac4ee32..f9ee28bbb 100644 --- a/src/lib/medication-seed.ts +++ b/src/lib/medication-seed.ts @@ -1,5 +1,6 @@ import { buildDefaultMedicationRows, defaultMedicationRecords } from "@/lib/medication-fixtures"; import { type MedicationRecordInsert, type MedicationRecordRow } from "@/lib/medication-records"; +import { invalidateOwnerCatalogueCache } from "@/lib/owner-catalogue-cache"; import { safeErrorLogDetails } from "@/lib/privacy"; type AdminClient = ReturnType; @@ -8,20 +9,36 @@ function loadRegistryCorpus() { return import("@/lib/registry-corpus"); } +type OwnerMedicationFetchOptions = { + signal?: AbortSignal; + select?: string; +}; + +function throwIfAborted(signal?: AbortSignal) { + if (!signal?.aborted) return; + throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +} + export function buildMedicationSeedRows(ownerId: string): MedicationRecordInsert[] { return buildDefaultMedicationRows(ownerId); } -export async function ensureMedicationsSeeded(supabase: AdminClient, ownerId: string): Promise { +export async function ensureMedicationsSeeded( + supabase: AdminClient, + ownerId: string, + options: Pick = {}, +): Promise { const rows = buildMedicationSeedRows(ownerId); - const { data, error } = await supabase - .from("medication_records") - .upsert(rows, { onConflict: "owner_id,slug" }) - .select("*"); + let query = supabase.from("medication_records").upsert(rows, { onConflict: "owner_id,slug" }).select("*"); + if (options.signal) query = query.abortSignal(options.signal); + const { data, error } = await query; if (error) throw new Error(`Medication seed failed: ${error.message}`); + invalidateOwnerCatalogueCache({ ownerId, kind: "medication" }); + throwIfAborted(options.signal); const seededRows = (data ?? []) as MedicationRecordRow[]; const { bestEffortSyncMedicationRows } = await loadRegistryCorpus(); await bestEffortSyncMedicationRows(supabase, seededRows); + throwIfAborted(options.signal); return seededRows; } @@ -36,23 +53,29 @@ export async function fetchOwnerMedicationRowsWithSeed( supabase: AdminClient, ownerId: string, maxRecords = 500, + options: OwnerMedicationFetchOptions = {}, ): Promise { const fetchRecords = async () => { - const { data, error } = await supabase + let query = supabase .from("medication_records") - .select("*") + .select(options.select ?? "*") .eq("owner_id", ownerId) .order("name") .limit(maxRecords); + if (options.signal) query = query.abortSignal(options.signal); + const { data, error } = await query; if (error) throw new Error(error.message); - return (data ?? []) as MedicationRecordRow[]; + throwIfAborted(options.signal); + // The optional projection is intentionally narrower than the generated + // table Row type; callers map only the fields they requested. + return (data ?? []) as unknown as MedicationRecordRow[]; }; let rows = await fetchRecords(); if (rows.length === 0) { let seedError: unknown = null; try { - await ensureMedicationsSeeded(supabase, ownerId); + await ensureMedicationsSeeded(supabase, ownerId, { signal: options.signal }); } catch (error) { seedError = error; console.error("[medications] auto-seed failed", safeErrorLogDetails(error)); diff --git a/src/lib/owner-catalogue-cache.ts b/src/lib/owner-catalogue-cache.ts new file mode 100644 index 000000000..1aab3ed88 --- /dev/null +++ b/src/lib/owner-catalogue-cache.ts @@ -0,0 +1,204 @@ +export type OwnerCatalogueKind = "medication" | "service" | "form"; + +type OwnerCatalogueEntry = { + ownerId: string; + kind: OwnerCatalogueKind; + limit: number; + expiresAt: number; + value: unknown[]; +}; + +type OwnerCatalogueFlight = { + ownerId: string; + kind: OwnerCatalogueKind; + limit: number; + controller: AbortController; + callers: number; + settled: boolean; + promise: Promise; +}; + +const ownerCatalogueTtlMs = 5_000; +const ownerCatalogueMaxEntries = 128; +const ownerCatalogueCache = new Map(); +const ownerCatalogueFlights = new Map(); + +function ownerCatalogueKey(ownerId: string, kind: OwnerCatalogueKind, limit: number) { + return JSON.stringify([ownerId, kind, limit]); +} + +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); +} + +function readOwnerCatalogue(key: string, now: number): T[] | undefined { + const entry = ownerCatalogueCache.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= now) { + ownerCatalogueCache.delete(key); + return undefined; + } + + // LRU recency bump. The cached catalogue is independent of the search query, so every + // typeahead prefix for the same owner/kind/limit can reuse this successful snapshot. + ownerCatalogueCache.delete(key); + ownerCatalogueCache.set(key, entry); + return entry.value as T[]; +} + +function writeOwnerCatalogue(args: { + key: string; + ownerId: string; + kind: OwnerCatalogueKind; + limit: number; + value: T[]; + now: number; +}) { + ownerCatalogueCache.delete(args.key); + ownerCatalogueCache.set(args.key, { + ownerId: args.ownerId, + kind: args.kind, + limit: args.limit, + value: args.value, + expiresAt: args.now + ownerCatalogueTtlMs, + }); + while (ownerCatalogueCache.size > ownerCatalogueMaxEntries) { + const oldest = ownerCatalogueCache.keys().next().value; + if (oldest === undefined) break; + ownerCatalogueCache.delete(oldest); + } +} + +function startOwnerCatalogueFlight(args: { + key: string; + ownerId: string; + kind: OwnerCatalogueKind; + limit: number; + load: (signal: AbortSignal) => Promise; +}) { + const controller = new AbortController(); + let removeAbortListener: () => void = () => undefined; + const aborted = new Promise((_resolve, reject) => { + const onAbort = () => reject(abortReason(controller.signal)); + controller.signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => controller.signal.removeEventListener("abort", onAbort); + }); + const load = Promise.resolve().then(() => args.load(controller.signal)); + const flight: OwnerCatalogueFlight = { + ownerId: args.ownerId, + kind: args.kind, + limit: args.limit, + controller, + callers: 0, + settled: false, + // Replaced synchronously below before the flight is published. + promise: Promise.resolve([]), + }; + flight.promise = Promise.race([load, aborted]) + .then((value) => { + // A successful load is cacheable only while at least one non-aborted + // caller still wants it. If every caller left, the shared controller is + // aborted and even a loader that ignored its signal cannot populate the cache. + if (controller.signal.aborted || flight.callers === 0) throw abortReason(controller.signal); + writeOwnerCatalogue({ ...args, value, now: Date.now() }); + return value; + }) + .finally(() => { + removeAbortListener(); + flight.settled = true; + if (ownerCatalogueFlights.get(args.key) === flight) ownerCatalogueFlights.delete(args.key); + }); + ownerCatalogueFlights.set(args.key, flight); + return flight; +} + +function joinOwnerCatalogueFlight(flight: OwnerCatalogueFlight, signal?: AbortSignal): Promise { + throwIfAborted(signal); + flight.callers += 1; + + return new Promise((resolve, reject) => { + let callerSettled = false; + const finish = (complete: () => void) => { + if (callerSettled) return; + callerSettled = true; + signal?.removeEventListener("abort", onAbort); + flight.callers = Math.max(0, flight.callers - 1); + if (flight.callers === 0 && !flight.settled) { + flight.controller.abort(new DOMException("No active catalogue callers.", "AbortError")); + } + complete(); + }; + const onAbort = () => + finish(() => reject(signal ? abortReason(signal) : new DOMException("Aborted", "AbortError"))); + + signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) { + onAbort(); + return; + } + + flight.promise.then( + (value) => { + if (signal?.aborted) { + onAbort(); + return; + } + finish(() => resolve(value as T[])); + }, + (error: unknown) => finish(() => reject(error)), + ); + }); +} + +/** + * Cache only completed owner-catalogue values. Pending promises, rejected loads, and values + * produced after cancellation are deliberately never inserted. + */ +export async function loadOwnerCatalogue(args: { + ownerId: string; + kind: OwnerCatalogueKind; + limit: number; + signal?: AbortSignal; + load: (signal: AbortSignal) => Promise; +}): Promise { + throwIfAborted(args.signal); + const key = ownerCatalogueKey(args.ownerId, args.kind, args.limit); + const cached = readOwnerCatalogue(key, Date.now()); + if (cached) return cached; + + let flight = ownerCatalogueFlights.get(key); + if (flight?.controller.signal.aborted) { + if (ownerCatalogueFlights.get(key) === flight) ownerCatalogueFlights.delete(key); + flight = undefined; + } + flight ??= startOwnerCatalogueFlight({ ...args, key }); + return joinOwnerCatalogueFlight(flight, args.signal); +} + +/** Remove every cached limit for the catalogue that was mutated. */ +export function invalidateOwnerCatalogueCache(args: { ownerId: string; kind?: OwnerCatalogueKind }) { + for (const [key, entry] of ownerCatalogueCache) { + if (entry.ownerId === args.ownerId && (!args.kind || entry.kind === args.kind)) { + ownerCatalogueCache.delete(key); + } + } + for (const [key, flight] of ownerCatalogueFlights) { + if (flight.ownerId === args.ownerId && (!args.kind || flight.kind === args.kind)) { + ownerCatalogueFlights.delete(key); + flight.controller.abort(new DOMException("Owner catalogue invalidated.", "AbortError")); + } + } +} + +/** Test/process-lifecycle helper; production invalidation should stay owner/kind scoped. */ +export function clearOwnerCatalogueCache() { + ownerCatalogueCache.clear(); + for (const flight of ownerCatalogueFlights.values()) { + flight.controller.abort(new DOMException("Owner catalogue cache cleared.", "AbortError")); + } + ownerCatalogueFlights.clear(); +} diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts index 90ee32774..a32ceae06 100644 --- a/src/lib/rag-cache.ts +++ b/src/lib/rag-cache.ts @@ -31,6 +31,14 @@ const cacheIndexingVersionTtlMs = 5000; const cacheIndexingVersionMaxEntries = 512; const cacheIndexingVersionCache = new Map(); +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); +} + function documentScopeKey(args: Pick) { const scope = args.documentIds?.length ? [...args.documentIds].sort().join(",") @@ -264,6 +272,7 @@ export async function getCachedSearch( queryClass?: RagQueryClass, queryVariants: string[] = [], ): Promise<{ results: SearchResult[]; telemetry: SearchTelemetry } | null> { + throwIfAborted(args.signal); if (!isSearchCacheEnabled(args)) return null; const key = scopedSearchCacheKey(args, queryClass, queryVariants); @@ -274,6 +283,7 @@ export async function getCachedSearch( return null; } const indexingVersion = await cacheIndexingVersion(args); + throwIfAborted(args.signal); if (cached.indexingVersion !== indexingVersion) { searchCache.delete(key); return null; @@ -303,10 +313,12 @@ export async function setCachedSearch( queryVariants: string[] = [], options?: { indexingVersionAtRetrievalStart?: string | null }, ): Promise { + throwIfAborted(args.signal); if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return; const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry); const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); + throwIfAborted(args.signal); if (options?.indexingVersionAtRetrievalStart && indexingVersion !== options.indexingVersionAtRetrievalStart) return; const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants); searchCache.set(key, { @@ -333,7 +345,7 @@ function sharedCacheSelector( kind: SharedCacheKind, args: Pick< SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding" + "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding" | "signal" >, indexingVersion: string, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), @@ -354,9 +366,10 @@ function sharedCacheSelector( } export async function cacheIndexingVersion( - args: Pick, + args: Pick, options?: { forceRefresh?: boolean }, ) { + throwIfAborted(args.signal); const cacheKey = cacheIndexingVersionCacheKey(args); if (options?.forceRefresh) cacheIndexingVersionCache.delete(cacheKey); const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey); @@ -381,7 +394,9 @@ export async function cacheIndexingVersion( query = query.is("owner_id", null); } if (documentFilters?.length) query = query.in("id", documentFilters); + if (args.signal) query = query.abortSignal(args.signal); const { data, error } = await query; + throwIfAborted(args.signal); if (error || !data?.length) { value = `${ragDeepMemoryVersion}:no-indexed-documents`; } else { @@ -395,8 +410,10 @@ export async function cacheIndexingVersion( value = `${ragDeepMemoryVersion}:${latest.id ?? "all"}:${indexedAt}:${generationId}`; } } catch { + if (args.signal?.aborted) throw abortReason(args.signal); value = `${ragDeepMemoryVersion}:index-stamp-unavailable`; } + throwIfAborted(args.signal); writeBoundedExpiringCacheEntry( cacheIndexingVersionCache, cacheKey, @@ -415,17 +432,15 @@ export async function getSharedCachedSearch( | { kind: "miss"; reason: SharedCacheMissReason } | null > { + throwIfAborted(args.signal); if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0) return null; const normalizedQuery = retrievalPlanCacheQuery(args, queryClass, queryVariants); const indexingVersion = await cacheIndexingVersion(args); try { - const { data, error } = await sharedCacheSelector( - createAdminClient(), - "search", - args, - indexingVersion, - normalizedQuery, - ).maybeSingle(); + let query = sharedCacheSelector(createAdminClient(), "search", args, indexingVersion, normalizedQuery); + if (args.signal) query = query.abortSignal(args.signal); + const { data, error } = await query.maybeSingle(); + throwIfAborted(args.signal); if (error) return { kind: "miss", reason: "cache_lookup_error" }; // The selector deliberately folds TTL, dependency, and indexing-version // validity into the hit query. Keep a filtered miss coarse rather than pay @@ -482,6 +497,7 @@ export async function getSharedCachedSearch( }, }; } catch { + if (args.signal?.aborted) throw abortReason(args.signal); return { kind: "miss", reason: "cache_lookup_exception" }; } } @@ -526,7 +542,7 @@ async function replaceSharedCacheRow( kind: SharedCacheKind, args: Pick< SearchChunksArgs, - "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding" + "query" | "documentId" | "documentIds" | "ownerId" | "accessScope" | "queryMode" | "forceEmbedding" | "signal" >, payload: unknown, ttlMs: number, @@ -534,6 +550,7 @@ async function replaceSharedCacheRow( ) { if (ttlMs <= 0) return; try { + if (args.signal?.aborted) return; const supabase = createAdminClient(); const indexingVersion = await cacheIndexingVersion(args); let deleteQuery = supabase @@ -545,8 +562,10 @@ async function replaceSharedCacheRow( .eq("indexing_version", indexingVersion) .eq("dependency_version", ragCacheDependencyVersion); deleteQuery = args.ownerId ? deleteQuery.eq("owner_id", args.ownerId) : deleteQuery.is("owner_id", null); + if (args.signal) deleteQuery = deleteQuery.abortSignal(args.signal); await deleteQuery; - await supabase.from("rag_response_cache").insert({ + if (args.signal?.aborted) return; + let insertQuery = supabase.from("rag_response_cache").insert({ owner_id: args.ownerId ?? null, cache_kind: kind, scope_key: scopeKey(args), @@ -557,6 +576,8 @@ async function replaceSharedCacheRow( payload: payload as Json, expires_at: new Date(Date.now() + ttlMs).toISOString(), }); + if (args.signal) insertQuery = insertQuery.abortSignal(args.signal); + await insertQuery; } catch { // Shared cache must never be part of the correctness path. } @@ -654,9 +675,12 @@ function invalidateAnonymousSharedRagCaches() { })(); } -export function invalidateRagCachesForDocumentMutation(ownerId: string) { +export function invalidateRagCachesForDocumentMutation( + ownerId: string, + options: { affectsPublicCorpus?: boolean } = {}, +) { invalidateRagCachesForOwner(ownerId); - invalidateAnonymousSharedRagCaches(); + if (options.affectsPublicCorpus !== false) invalidateAnonymousSharedRagCaches(); } function sourceContextPackLimit(queryClass: RagQueryClass, options: { crossDocument?: boolean } = {}) { diff --git a/src/lib/rag-candidate-sources.ts b/src/lib/rag-candidate-sources.ts index b62be11a5..cdb236174 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) { @@ -705,6 +743,7 @@ export async function loadChunksForSignalMatches(args: { matches: ChunkSignalMatch[]; ownerId?: string; accessScope?: RetrievalAccessScope; + signal?: AbortSignal; }) { const bestMatchByChunk = new Map(); for (const match of args.matches) { @@ -715,11 +754,12 @@ export async function loadChunksForSignalMatches(args: { if (chunkIds.length === 0) return [] as SearchResult[]; const accessScope = retrievalAccessScopeForArgs(args); - const { data: chunkScopes, error: chunkScopesError } = await args.supabase + const chunkScopesQuery = args.supabase .from("document_chunks") .select("id,document_id") .in("id", chunkIds) .limit(chunkIds.length); + const { data: chunkScopes, error: chunkScopesError } = await resolveQuery(chunkScopesQuery, args.signal); if (chunkScopesError || !chunkScopes?.length) return [] as SearchResult[]; const documentIds = Array.from(new Set(chunkScopes.map((chunk) => chunk.document_id))); @@ -735,7 +775,7 @@ export async function loadChunksForSignalMatches(args: { } else { documentQuery = documentQuery.is("owner_id", null); } - const { data: documents, error: documentsError } = await documentQuery; + const { data: documents, error: documentsError } = await resolveQuery(documentQuery, args.signal); if (documentsError || !documents?.length) return [] as SearchResult[]; const documentById = new Map(documents.map((document) => [document.id, document])); const allowedDocumentIds = new Set(documentById.keys()); @@ -743,7 +783,7 @@ export async function loadChunksForSignalMatches(args: { .filter((chunk) => allowedDocumentIds.has(chunk.document_id)) .map((chunk) => chunk.id); if (allowedChunkIds.length === 0) return [] as SearchResult[]; - const { data: chunks, error: chunksError } = await args.supabase + const chunksQuery = 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", @@ -751,6 +791,7 @@ export async function loadChunksForSignalMatches(args: { .in("id", allowedChunkIds) .in("document_id", [...allowedDocumentIds]) .limit(allowedChunkIds.length); + const { data: chunks, error: chunksError } = await resolveQuery(chunksQuery, args.signal); if (chunksError || !chunks?.length) return [] as SearchResult[]; return chunks @@ -820,6 +861,7 @@ export async function searchTableFactCandidates(args: { allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; + signal?: AbortSignal; }) { const variants = (args.queryVariants?.length ? args.queryVariants : [buildClinicalTextSearchQuery(args.query)]).slice( 0, @@ -836,6 +878,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[]; @@ -876,6 +919,7 @@ export async function searchTableFactCandidates(args: { matches: Array.from(grouped.values()), ownerId: args.ownerId, accessScope: args.accessScope, + signal: args.signal, }); } @@ -890,6 +934,7 @@ export async function searchEmbeddingFieldCandidates(args: { allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; + signal?: AbortSignal; }) { const { data, error } = await callVersionedRetrievalRpc( args.supabase, @@ -903,6 +948,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[]; @@ -939,6 +985,7 @@ export async function searchEmbeddingFieldCandidates(args: { matches, ownerId: args.ownerId, accessScope: args.accessScope, + signal: args.signal, }); } @@ -953,6 +1000,7 @@ export async function searchIndexUnitCandidates(args: { allowGlobalSearch?: boolean; matchCount: number; telemetry?: SearchTelemetry; + signal?: AbortSignal; }) { const { data, error } = await callVersionedRetrievalRpc( args.supabase, @@ -966,6 +1014,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[]; @@ -1004,6 +1053,7 @@ export async function searchIndexUnitCandidates(args: { matches, ownerId: args.ownerId, accessScope: args.accessScope, + signal: args.signal, }); } @@ -1020,6 +1070,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. @@ -1042,13 +1093,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-retrieval-variants.ts b/src/lib/rag-retrieval-variants.ts index 564665266..ea37c693e 100644 --- a/src/lib/rag-retrieval-variants.ts +++ b/src/lib/rag-retrieval-variants.ts @@ -19,6 +19,14 @@ const maxRagAliasCacheEntries = 256; const maxRagAliasesPerScope = 200; const maxRagAliasExpansions = 12; +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); +} + /** Text candidate budget for query class. */ export function textCandidateBudgetForQueryClass(queryClass: RagQueryClass | undefined, topK: number) { if (queryClass === "comparison") return Math.max(topK * 7, 72); @@ -109,7 +117,9 @@ export async function fetchEnabledRagAliases( supabase: ReturnType, ownerId?: string, accessScope?: RetrievalAccessScope, + signal?: AbortSignal, ): Promise { + throwIfAborted(signal); const scope = retrievalAccessScopeForArgs({ ownerId, accessScope }); const cacheKey = retrievalAccessScopeKey(scope); const cached = readExpiringCacheEntry(ragAliasCache, cacheKey); @@ -124,7 +134,9 @@ export async function fetchEnabledRagAliases( .order("weight", { ascending: false }) .limit(maxRagAliasesPerScope); query = scopeOwnerId ? query.eq("owner_id", scopeOwnerId) : query.is("owner_id", null); + if (signal) query = query.abortSignal(signal); const { data, error } = await query; + throwIfAborted(signal); if (error) throw error; return (data ?? []) as RagAliasInput[]; } @@ -143,6 +155,7 @@ export async function fetchEnabledRagAliases( merged.push(alias); if (merged.length >= maxRagAliasesPerScope) break; } + throwIfAborted(signal); writeBoundedExpiringCacheEntry( ragAliasCache, cacheKey, @@ -151,6 +164,7 @@ export async function fetchEnabledRagAliases( ); return merged; } catch { + if (signal?.aborted) throw abortReason(signal); // Do not cache an empty result on a transient rag_aliases read failure: caching [] would suppress // alias-based query expansion (and could let an alias-rescuable query short-circuit) for the whole // TTL. Return empty for this call only and retry on the next call. diff --git a/src/lib/rag.ts b/src/lib/rag.ts index a2942d577..e9c544d28 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -416,9 +416,13 @@ 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); } } @@ -1289,6 +1293,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 ( @@ -1317,6 +1323,7 @@ export async function analyzeQueryWithClassifierFallback( supabase: opts.corpusGrounding.supabase, query, ownerFilter: opts.corpusGrounding.ownerFilter, + signal: opts.signal, }); if (grounding.verdict === "in_corpus_topic") { return { @@ -1342,7 +1349,7 @@ 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; const memoKey = classifierVerdictMemoKey(query, analysis); const memoized = classifierVerdictMemo.get(memoKey); @@ -1512,6 +1519,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; @@ -1541,7 +1549,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([ @@ -1549,8 +1557,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; @@ -1587,6 +1599,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; @@ -1598,12 +1611,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; } } @@ -1612,6 +1628,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( @@ -1631,7 +1648,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") @@ -1642,7 +1659,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") @@ -1651,8 +1669,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; @@ -2224,6 +2254,8 @@ async function prepareCoverageGateResults(args: { queryClass: RagQueryClass; telemetry: SearchTelemetry; metadataCache: DocumentRankingMetadataCache; + includeVisualEvidence?: boolean; + signal?: AbortSignal; }) { const startedAt = Date.now(); const candidates = await attachDocumentRankingMetadata( @@ -2231,18 +2263,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, @@ -2361,6 +2395,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(); @@ -2396,6 +2432,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; @@ -2407,7 +2445,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; @@ -2447,10 +2485,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 }); } @@ -2485,6 +2526,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; @@ -2502,6 +2544,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { textData as SearchResult[], args.ownerId, documentRankingMetadataCache, + args.signal, ); expandedQuery = expandClinicalQueryWithCandidateMetadata(args.query, expandedQuery, textCandidates); const baseTextResults = selectRankedRetrievalResults({ @@ -2515,7 +2558,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, @@ -2539,6 +2582,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( @@ -2556,7 +2600,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, @@ -2590,6 +2634,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), telemetry, + signal: args.signal, }); const tableFactLatencyMs = Date.now() - tableFactStartedAt; telemetry.supabase_rpc_latency_ms += tableFactLatencyMs; @@ -2613,6 +2658,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; @@ -2628,6 +2674,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({ @@ -2639,6 +2686,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( @@ -2653,8 +2701,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, @@ -2704,6 +2751,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); @@ -2776,6 +2825,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 48), telemetry, + signal: args.signal, }); return { candidates, latencyMs: Date.now() - startedAt }; })(), @@ -2791,6 +2841,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { allowGlobalSearch: args.allowGlobalSearch, matchCount: Math.min(candidateCount, 64), telemetry, + signal: args.signal, }); return { candidates, latencyMs: Date.now() - startedAt }; })(), @@ -2808,6 +2859,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filters: documentFilterList ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); return { data, error, latencyMs: Date.now() - startedAt }; })(), @@ -2858,6 +2910,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { merged, args.ownerId, documentRankingMetadataCache, + args.signal, ); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -2869,6 +2922,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( @@ -2885,6 +2939,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }), + args.signal, ); results = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, @@ -2915,6 +2970,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { document_filter: documentFilter ?? undefined, ...retrievalRpcScopeArgs(retrievalAccessScopeForArgs(args)), }, + args.signal, ); if (error) throw new Error(error.message); @@ -2942,6 +2998,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, documentRankingMetadataCache, + args.signal, ); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -2953,6 +3010,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( @@ -2969,6 +3027,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { maxResultsPerDocument, telemetry, }), + args.signal, ); results = applySecondStageRerankIfNeeded({ queryClass: queryClassification.queryClass, diff --git a/src/lib/registry-seed.ts b/src/lib/registry-seed.ts index ea7e1e840..60ec5510a 100644 --- a/src/lib/registry-seed.ts +++ b/src/lib/registry-seed.ts @@ -1,4 +1,5 @@ import { formRecords } from "@/lib/forms"; +import { invalidateOwnerCatalogueCache } from "@/lib/owner-catalogue-cache"; import { buildDefaultFormRows, buildDefaultServiceRows, defaultServiceRecords } from "@/lib/registry-fixtures"; import { deriveGovernanceColumns, @@ -21,6 +22,16 @@ function loadRegistryCorpus() { return import("@/lib/registry-corpus"); } +type OwnerRegistryFetchOptions = { + signal?: AbortSignal; + select?: string; +}; + +function throwIfAborted(signal?: AbortSignal) { + if (!signal?.aborted) return; + throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +} + /** The curated shared registry fixtures for a kind — the same baseline the CLI * can materialize and every API caller receives automatically. */ export function defaultRegistryRecords(kind: RegistryRecordKind) { @@ -146,16 +157,22 @@ export async function ensureRegistrySeeded( supabase: AdminClient, ownerId: string, kind: RegistryRecordKind, + options: Pick = {}, ): Promise { const rows = buildDefaultRegistryRows(ownerId, kind); - const { data, error } = await supabase + let query = supabase .from("clinical_registry_records") .upsert(rows, { onConflict: "owner_id,kind,slug", ignoreDuplicates: true }) .select("*"); + if (options.signal) query = query.abortSignal(options.signal); + const { data, error } = await query; if (error) throw new Error(`Registry seed failed: ${error.message}`); + invalidateOwnerCatalogueCache({ ownerId, kind }); + throwIfAborted(options.signal); const seededRows = (data ?? []) as RegistryRecordRow[]; const { bestEffortSyncClinicalRegistryRows } = await loadRegistryCorpus(); await bestEffortSyncClinicalRegistryRows(supabase, seededRows); + throwIfAborted(options.signal); return seededRows; } @@ -169,14 +186,20 @@ export async function fetchOwnerRegistryRows( ownerId: string, kind: RegistryRecordKind, maxRecords = 500, + options: OwnerRegistryFetchOptions = {}, ): Promise { - const { data, error } = await supabase + let query = supabase .from("clinical_registry_records") - .select("*") + .select(options.select ?? "*") .eq("owner_id", ownerId) .eq("kind", kind) .order("title") .limit(maxRecords); + if (options.signal) query = query.abortSignal(options.signal); + const { data, error } = await query; if (error) throw new Error(error.message); - return (data ?? []) as RegistryRecordRow[]; + throwIfAborted(options.signal); + // The optional projection is intentionally narrower than the generated + // table Row type; callers map only the fields they requested. + return (data ?? []) as unknown as RegistryRecordRow[]; } diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index f387fa44c..c003ea2f1 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -1,6 +1,5 @@ import type { AppModeId } from "@/lib/app-modes"; -import type { ServiceRecord } from "@/lib/services"; -import { serviceRecordSearchText } from "@/lib/services"; +import { serviceRecordSearchText, type ServiceRecord } from "@/lib/service-ranker"; export type CommandScopeChip = { id: string; diff --git a/src/lib/service-ranker.ts b/src/lib/service-ranker.ts new file mode 100644 index 000000000..b67912e51 --- /dev/null +++ b/src/lib/service-ranker.ts @@ -0,0 +1,165 @@ +import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; + +export type ServiceChipTone = "danger" | "info" | "warning" | "success" | "neutral"; +export type ServiceCriterionTone = "meet" | "caution" | "reject"; + +export type ServiceStatusChip = { + label?: string | null; + tone?: ServiceChipTone | null; +}; + +export type ServiceContact = { + label: string; + value?: string | null; + detail?: string | null; + kind: "phone" | "email" | "web" | "text" | "unknown"; +}; + +export type ServiceSummaryCard = { + id: string; + label?: string | null; + title?: string | null; + detail?: string | null; +}; + +export type ServiceInfoRow = { + label: string; + value?: string | null; +}; + +export type ServiceCriterion = { + label: string; + tone: ServiceCriterionTone; +}; + +export type ServiceVerification = { + locallyVerified?: boolean | null; + confidence?: "High" | "Medium" | "Low" | "Unknown" | null; + notes?: string[] | null; +}; + +export type ServiceSource = { + label?: string | null; + status?: string | null; + url?: string | null; + published?: string | null; + reviewed?: string | null; + notes?: string[] | null; +}; + +export type ServiceRecord = { + slug: string; + title: string; + subtitle?: string; + statusChips?: ServiceStatusChip[]; + primaryContact?: ServiceContact; + contacts?: ServiceContact[]; + route?: string; + eligibility?: string; + cost?: string; + referral?: string; + location?: string; + summaryCards?: ServiceSummaryCard[]; + referralInfo?: ServiceInfoRow[]; + bestUse?: string; + criteria?: ServiceCriterion[]; + verification?: ServiceVerification; + tags?: string[]; + catchments?: string[]; + catalogueLabel?: string; + navigatorQuery?: string; + source?: ServiceSource; + /** Full source-specific payload retained in the registry JSONB column. */ + catalogPayload?: Record; +}; + +export type ServiceSearchMatch = { + service: ServiceRecord; + score: number; + reasons: string[]; +}; + +export function serviceNavigatorQuery(service: ServiceRecord) { + return ( + [service.navigatorQuery, service.title, service.primaryContact?.value, service.subtitle].find((value) => + value?.trim(), + ) ?? service.slug + ); +} + +function serviceRecordSearchParts(service: ServiceRecord) { + return [ + service.title, + service.slug, + service.subtitle, + service.route, + service.eligibility, + service.cost, + service.referral, + service.location, + service.bestUse, + service.catalogueLabel, + service.navigatorQuery, + service.primaryContact?.value, + service.primaryContact?.detail, + service.source?.label, + service.source?.status, + service.source?.reviewed, + ...(service.tags ?? []), + ...(service.catchments ?? []), + ...(service.statusChips ?? []).flatMap((chip) => [chip.label, chip.tone]), + ...(service.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail, contact.kind]), + ...(service.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]), + ...(service.referralInfo ?? []).flatMap((row) => [row.label, row.value]), + ...(service.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]), + ...(service.verification?.notes ?? []), + ...(service.source?.notes ?? []), + "service", + "services", + "source record", + "pathway", + ].filter((value): value is string => Boolean(value?.trim())); +} + +export function serviceRecordSearchText(service: ServiceRecord) { + return normalizeSearchText(serviceRecordSearchParts(service).join(" ")); +} + +export function rankServiceRecords( + records: ServiceRecord[], + query: string, + limit = records.length, + // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane. + expansions: string[] = [], +): ServiceSearchMatch[] { + return rankCatalogRecords(records, query, { + fields: [ + { id: "title", weight: 6, text: (service) => normalizeSearchText(`${service.title} ${service.slug}`) }, + { id: "contact", weight: 5, text: (service) => normalizeSearchText(service.primaryContact?.value ?? "") }, + { + id: "tags", + weight: 3, + text: (service) => normalizeSearchText([...(service.tags ?? []), ...(service.catchments ?? [])].join(" ")), + }, + ], + fullText: serviceRecordSearchText, + contentWeight: 2, + compactBonus: 5, + phraseBonus: 4, + broadTerms: ["service", "services", "pathway", "pathways"], + broadBonus: 1, + expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined, + limit, + tieBreak: (left, right) => left.title.localeCompare(right.title), + }).map(({ record, score, signals }) => ({ + service: record, + score, + reasons: [ + signals.fields.title ? "title" : "", + signals.fields.contact || signals.compact ? "contact" : "", + signals.fields.tags ? "tags" : "", + signals.content ? "record fields" : "", + signals.broad ? "services catalogue" : "", + ].filter(Boolean), + })); +} diff --git a/src/lib/services.ts b/src/lib/services.ts index e9fe94574..d37a5d619 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -1,84 +1,7 @@ -import { normalizeSearchText, rankCatalogRecords } from "@/lib/catalog-search"; import { defaultServiceRecords } from "@/lib/registry-fixtures"; +import { rankServiceRecords, type ServiceRecord, type ServiceSearchMatch } from "@/lib/service-ranker"; -export type ServiceChipTone = "danger" | "info" | "warning" | "success" | "neutral"; -export type ServiceCriterionTone = "meet" | "caution" | "reject"; - -export type ServiceStatusChip = { - label?: string | null; - tone?: ServiceChipTone | null; -}; - -export type ServiceContact = { - label: string; - value?: string | null; - detail?: string | null; - kind: "phone" | "email" | "web" | "text" | "unknown"; -}; - -export type ServiceSummaryCard = { - id: string; - label?: string | null; - title?: string | null; - detail?: string | null; -}; - -export type ServiceInfoRow = { - label: string; - value?: string | null; -}; - -export type ServiceCriterion = { - label: string; - tone: ServiceCriterionTone; -}; - -export type ServiceVerification = { - locallyVerified?: boolean | null; - confidence?: "High" | "Medium" | "Low" | "Unknown" | null; - notes?: string[] | null; -}; - -export type ServiceSource = { - label?: string | null; - status?: string | null; - url?: string | null; - published?: string | null; - reviewed?: string | null; - notes?: string[] | null; -}; - -export type ServiceRecord = { - slug: string; - title: string; - subtitle?: string; - statusChips?: ServiceStatusChip[]; - primaryContact?: ServiceContact; - contacts?: ServiceContact[]; - route?: string; - eligibility?: string; - cost?: string; - referral?: string; - location?: string; - summaryCards?: ServiceSummaryCard[]; - referralInfo?: ServiceInfoRow[]; - bestUse?: string; - criteria?: ServiceCriterion[]; - verification?: ServiceVerification; - tags?: string[]; - catchments?: string[]; - catalogueLabel?: string; - navigatorQuery?: string; - source?: ServiceSource; - /** Full source-specific payload retained in the registry JSONB column. */ - catalogPayload?: Record; -}; - -export type ServiceSearchMatch = { - service: ServiceRecord; - score: number; - reasons: string[]; -}; +export * from "@/lib/service-ranker"; export const serviceRecords: ServiceRecord[] = defaultServiceRecords(); @@ -99,91 +22,6 @@ export function defaultServiceSlug() { return serviceRecords[0]?.slug ?? null; } -export function serviceNavigatorQuery(service: ServiceRecord) { - return ( - [service.navigatorQuery, service.title, service.primaryContact?.value, service.subtitle].find((value) => - value?.trim(), - ) ?? service.slug - ); -} - -function serviceRecordSearchParts(service: ServiceRecord) { - return [ - service.title, - service.slug, - service.subtitle, - service.route, - service.eligibility, - service.cost, - service.referral, - service.location, - service.bestUse, - service.catalogueLabel, - service.navigatorQuery, - service.primaryContact?.value, - service.primaryContact?.detail, - service.source?.label, - service.source?.status, - service.source?.reviewed, - ...(service.tags ?? []), - ...(service.catchments ?? []), - ...(service.statusChips ?? []).flatMap((chip) => [chip.label, chip.tone]), - ...(service.contacts ?? []).flatMap((contact) => [contact.label, contact.value, contact.detail, contact.kind]), - ...(service.summaryCards ?? []).flatMap((card) => [card.label, card.title, card.detail]), - ...(service.referralInfo ?? []).flatMap((row) => [row.label, row.value]), - ...(service.criteria ?? []).flatMap((criterion) => [criterion.label, criterion.tone]), - ...(service.verification?.notes ?? []), - ...(service.source?.notes ?? []), - "service", - "services", - "source record", - "pathway", - ].filter((value): value is string => Boolean(value?.trim())); -} - -export function serviceRecordSearchText(service: ServiceRecord) { - return normalizeSearchText(serviceRecordSearchParts(service).join(" ")); -} - -export function rankServiceRecords( - records: ServiceRecord[], - query: string, - limit = records.length, - // Low-weight synonym/acronym/alias terms (see rankMedicationRecords) for the expanded lane. - expansions: string[] = [], -): ServiceSearchMatch[] { - return rankCatalogRecords(records, query, { - fields: [ - { id: "title", weight: 6, text: (service) => normalizeSearchText(`${service.title} ${service.slug}`) }, - { id: "contact", weight: 5, text: (service) => normalizeSearchText(service.primaryContact?.value ?? "") }, - { - id: "tags", - weight: 3, - text: (service) => normalizeSearchText([...(service.tags ?? []), ...(service.catchments ?? [])].join(" ")), - }, - ], - fullText: serviceRecordSearchText, - contentWeight: 2, - compactBonus: 5, - phraseBonus: 4, - broadTerms: ["service", "services", "pathway", "pathways"], - broadBonus: 1, - expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined, - limit, - tieBreak: (left, right) => left.title.localeCompare(right.title), - }).map(({ record, score, signals }) => ({ - service: record, - score, - reasons: [ - signals.fields.title ? "title" : "", - signals.fields.contact || signals.compact ? "contact" : "", - signals.fields.tags ? "tags" : "", - signals.content ? "record fields" : "", - signals.broad ? "services catalogue" : "", - ].filter(Boolean), - })); -} - export function searchServiceRecords(query: string, limit = serviceRecords.length): ServiceSearchMatch[] { return rankServiceRecords(serviceRecords, query, limit); } diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index 8f20205c9..49b6bee29 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -78,7 +78,10 @@ export class AuthenticationError extends Error { export function unauthorizedResponse(error?: AuthenticationError) { void error; - return NextResponse.json({ error: "Authentication required." }, { status: 401 }); + return NextResponse.json( + { error: "Authentication required." }, + { status: 401, headers: { "Cache-Control": "private, no-store" } }, + ); } /** diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index f00a72ba9..c3bca51e5 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2262,6 +2262,26 @@ export type Database = { retry_after_seconds: number; }[]; }; + consume_summary_rate_limits_atomic: { + Args: { + p_answer_limit: number; + p_answer_window_seconds: number; + p_global_answer_limit: number; + p_global_answer_window_seconds: number; + p_owner_id: string | null; + p_subject_key: string | null; + p_summary_limit: number; + p_summary_window_seconds: number; + }; + Returns: { + bucket: string | null; + limit_value: number; + limited: boolean; + remaining: number; + reset_at: string; + retry_after_seconds: number; + }[]; + }; corpus_topic_term_stats: { Args: { terms: string[]; owner_filter?: string | null }; Returns: { diff --git a/src/lib/types.ts b/src/lib/types.ts index 12ab73e14..b2d6753c1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -440,6 +440,18 @@ export type Citation = { provenance?: CitationProvenance; }; +export type SafetyWarningKind = + "contraindication" | "red_flag" | "escalation" | "dose_limit" | "monitoring" | "exclusion" | "caveat"; + +export type SafetyWarning = { + id: string; + kind: SafetyWarningKind; + label: string; + text: string; + citation: Citation; + href: string; +}; + export type SupportedClaim = { claimId: string; text: string; @@ -1005,6 +1017,9 @@ export type RagAnswer = { }>; scope?: SearchScopeSummary; sourceGovernanceWarnings?: SourceGovernanceWarning[]; + /** Server-derived safety findings. Browser payloads use these bounded + * findings instead of scanning full source chunks client-side. */ + safetyWarnings?: SafetyWarning[]; // GEN-C1: set when the model output was cut off (status="incomplete" / // max_output_tokens). The clinical content may be missing a dose/threshold, so // the UI must surface "answer truncated — verify against sources". diff --git a/src/lib/universal-search-stream.ts b/src/lib/universal-search-stream.ts new file mode 100644 index 000000000..c3ba49156 --- /dev/null +++ b/src/lib/universal-search-stream.ts @@ -0,0 +1,84 @@ +import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search"; + +export type UniversalSearchStreamResponse = UniversalSearchResponse & { + demoMode?: boolean; + publicAccess?: boolean; +}; + +export type UniversalSearchStreamEvent = + | { type: "group"; query: string; group: UniversalSearchGroup } + | { type: "complete"; response: UniversalSearchStreamResponse }; + +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); +} + +function parseEvent(line: string): UniversalSearchStreamEvent { + const parsed = JSON.parse(line) as Partial; + if (parsed.type === "group" && typeof parsed.query === "string" && parsed.group) { + return parsed as Extract; + } + if (parsed.type === "complete" && parsed.response) { + return parsed as Extract; + } + throw new Error("Invalid universal-search NDJSON event."); +} + +/** Consume split NDJSON chunks, surfacing groups immediately and returning final JSON parity. */ +export async function consumeUniversalSearchNdjson( + response: Response, + options: { + signal?: AbortSignal; + onGroup?: (group: UniversalSearchGroup, query: string) => void | Promise; + } = {}, +): Promise { + throwIfAborted(options.signal); + if (!response.body) throw new Error("Universal-search NDJSON response has no body."); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let complete: UniversalSearchStreamResponse | undefined; + const cancelReader = () => { + void reader.cancel(options.signal?.reason).catch(() => undefined); + }; + options.signal?.addEventListener("abort", cancelReader, { once: true }); + + const acceptLine = async (line: string) => { + if (!line.trim()) return; + const event = parseEvent(line); + if (event.type === "group") await options.onGroup?.(event.group, event.query); + else complete = event.response; + }; + + try { + while (true) { + throwIfAborted(options.signal); + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + await acceptLine(line); + newline = buffer.indexOf("\n"); + } + } + buffer += decoder.decode(); + if (buffer.trim()) await acceptLine(buffer); + throwIfAborted(options.signal); + if (!complete) throw new Error("Universal-search NDJSON stream ended without a complete event."); + return complete; + } catch (error) { + if (options.signal?.aborted) throw abortReason(options.signal); + throw error; + } finally { + options.signal?.removeEventListener("abort", cancelReader); + reader.releaseLock(); + } +} diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index 1901c2a35..b6fe3d872 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -14,6 +14,7 @@ import { formRecords, rankFormRecords, type FormRecord } from "@/lib/forms"; import { rowToMedicationRecord } from "@/lib/medication-records"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; import { medicationIndication, rankMedicationRecords, type MedicationRecord } from "@/lib/medications"; +import { loadOwnerCatalogue } from "@/lib/owner-catalogue-cache"; import { searchChunksWithTelemetry } from "@/lib/rag"; import { registryCorpusDetailHref } from "@/lib/registry-corpus-links"; import { fetchOwnerRegistryRows, mergeRegistryRecordsWithDefaults } from "@/lib/registry-seed"; @@ -109,6 +110,10 @@ export type RunUniversalSearchArgs = { supabase?: AdminClient; ownerId?: string; demo: boolean; + signal?: AbortSignal; + // Optional progressive delivery hook. Domain work remains parallel; each completed group is + // reported immediately while the final response preserves canonical group order. + onGroup?: (group: UniversalSearchGroup) => void | Promise; }; // Args after query understanding: registry adapters rank against baseQuery (typo-corrected) @@ -122,21 +127,74 @@ type ResolvedSearchArgs = RunUniversalSearchArgs & { const registryDomainTimeoutMs = 2500; const documentsDomainTimeoutMs = 6000; +const ownerCatalogueLimit = 500; + +// Owner typeahead needs the complete rankable catalogue, but not governance timestamps, IDs, +// audit columns, or other route-only payload. These projections keep the short-lived cache and +// Supabase response limited to fields consumed by row conversion, ranking, and result cards. +const medicationRankingProjection = "slug,name,class,subclass,category,tag,schedule,stats,sections,quick"; +const registryRankingProjection = [ + "slug", + "title", + "subtitle", + "status_chips", + "primary_contact", + "contacts", + "route", + "eligibility", + "cost", + "referral", + "location", + "summary_cards", + "referral_info", + "best_use", + "criteria", + "verification", + "tags", + "catchments", + "catalogue_label", + "navigator_query", + "source", + "catalog_payload", +].join(","); + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); +} -function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${label} search timed out after ${timeoutMs}ms`)), timeoutMs); - promise.then( - (value) => { - clearTimeout(timer); - resolve(value); - }, - (error: unknown) => { - clearTimeout(timer); - reject(error instanceof Error ? error : new Error(String(error))); - }, - ); +function throwIfAborted(signal?: AbortSignal) { + if (signal?.aborted) throw abortReason(signal); +} + +async function withTimeout( + run: (signal: AbortSignal) => Promise, + timeoutMs: number, + label: string, + callerSignal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const abortFromCaller = () => controller.abort(callerSignal?.reason); + if (callerSignal?.aborted) abortFromCaller(); + else callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); + + const timeout = setTimeout(() => { + controller.abort(new DOMException(`${label} search timed out after ${timeoutMs}ms`, "TimeoutError")); + }, timeoutMs); + let rejectOnAbort: ((reason: Error) => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + rejectOnAbort = reject; }); + const onAbort = () => rejectOnAbort?.(abortReason(controller.signal)); + controller.signal.addEventListener("abort", onAbort, { once: true }); + if (controller.signal.aborted) onAbort(); + + try { + return await Promise.race([run(controller.signal), aborted]); + } finally { + clearTimeout(timeout); + callerSignal?.removeEventListener("abort", abortFromCaller); + controller.signal.removeEventListener("abort", onAbort); + } } function medicationItem(record: MedicationRecord, score: number): UniversalSearchItem { @@ -180,7 +238,19 @@ function formItem(record: FormRecord, score: number): UniversalSearchItem { async function searchMedicationsDomain(args: ResolvedSearchArgs): Promise { const records = !args.demo && args.supabase && args.ownerId - ? (await fetchOwnerMedicationRowsWithSeed(args.supabase, args.ownerId)).map(rowToMedicationRecord) + ? ( + await loadOwnerCatalogue({ + ownerId: args.ownerId, + kind: "medication", + limit: ownerCatalogueLimit, + signal: args.signal, + load: (signal) => + fetchOwnerMedicationRowsWithSeed(args.supabase!, args.ownerId!, ownerCatalogueLimit, { + signal, + select: medicationRankingProjection, + }), + }) + ).map(rowToMedicationRecord) : defaultMedicationRecords(); return rankMedicationRecords(records, args.baseQuery, args.limitPerDomain, args.expansions).map((match) => medicationItem(match.medication, match.score), @@ -192,7 +262,17 @@ async function searchServicesDomain(args: ResolvedSearchArgs): Promise + fetchOwnerRegistryRows(args.supabase!, args.ownerId!, "service", ownerCatalogueLimit, { + signal, + select: registryRankingProjection, + }), + }), ) : serviceRecords; return rankServiceRecords(records, args.baseQuery, args.limitPerDomain, args.expansions).map((match) => @@ -203,7 +283,20 @@ async function searchServicesDomain(args: ResolvedSearchArgs): Promise { const records = !args.demo && args.supabase && args.ownerId - ? mergeRegistryRecordsWithDefaults("form", await fetchOwnerRegistryRows(args.supabase, args.ownerId, "form")) + ? mergeRegistryRecordsWithDefaults( + "form", + await loadOwnerCatalogue({ + ownerId: args.ownerId, + kind: "form", + limit: ownerCatalogueLimit, + signal: args.signal, + load: (signal) => + fetchOwnerRegistryRows(args.supabase!, args.ownerId!, "form", ownerCatalogueLimit, { + signal, + select: registryRankingProjection, + }), + }), + ) : formRecords; return rankFormRecords(records, args.baseQuery, args.limitPerDomain, args.expansions).map((match) => formItem(match.service, match.score), @@ -372,6 +465,7 @@ async function searchDocumentsDomain(args: ResolvedSearchArgs): Promise 0) { return related.map((document) => ({ @@ -528,6 +624,7 @@ function buildInterpretation( export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise { const startedAt = Date.now(); + throwIfAborted(args.signal); const requested = args.domains?.length ? universalSearchDomains.filter((domain) => args.domains!.includes(domain)) : universalSearchDomains; @@ -540,31 +637,42 @@ export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise< const expansions = deriveExpansions(analysis, baseQuery); const resolved: ResolvedSearchArgs = { ...args, baseQuery, expansions }; - const settled = await Promise.allSettled( + const groups = await Promise.all( requested.map(async (domain): Promise => { const domainStartedAt = Date.now(); const adapter = domainAdapters[domain]; - const items = await withTimeout(adapter.run(resolved), adapter.timeoutMs, domain); - // Tag near-exact title matches so best-bet + ordering can compare across domains without - // leaning on the per-domain scores (which are not comparable across domains). - const tagged = items - .slice(0, args.limitPerDomain) - .map((item) => ({ ...item, confident: titleMatchesQuery(item.title, baseQuery) })); - return { - kind: domain, - total: tagged.length, - items: tagged, - latencyMs: Date.now() - domainStartedAt, - }; + let group: UniversalSearchGroup; + try { + const items = await withTimeout( + (signal) => adapter.run({ ...resolved, signal }), + adapter.timeoutMs, + domain, + args.signal, + ); + // Tag near-exact title matches so best-bet + ordering can compare across domains without + // leaning on the per-domain scores (which are not comparable across domains). + const tagged = items + .slice(0, args.limitPerDomain) + .map((item) => ({ ...item, confident: titleMatchesQuery(item.title, baseQuery) })); + group = { + kind: domain, + total: tagged.length, + items: tagged, + latencyMs: Date.now() - domainStartedAt, + }; + } catch { + // A failed/timed-out domain yields an empty errored group — one slow or broken adapter + // must never blank the whole response. Caller cancellation is different: propagate it + // so all downstream work and the NDJSON stream terminate instead of caching/returning. + throwIfAborted(args.signal); + group = { kind: domain, total: 0, items: [], latencyMs: Date.now() - domainStartedAt, error: true }; + } + throwIfAborted(args.signal); + await args.onGroup?.(group); + return group; }), ); - - // A failed domain yields an empty errored group — one slow or broken adapter must never - // blank the whole response. - const groups = settled.map((result, index): UniversalSearchGroup => { - if (result.status === "fulfilled") return result.value; - return { kind: requested[index], total: 0, items: [], latencyMs: Date.now() - startedAt, error: true }; - }); + throwIfAborted(args.signal); const preferredDomains = universalSearchPreferredDomains(args.contextMode).filter((domain) => requested.includes(domain), diff --git a/src/proxy.ts b/src/proxy.ts index 2367aeb9e..d01c51386 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -115,9 +115,9 @@ export async function proxy(request: NextRequest) { }); // Refresh the session. Per @supabase/ssr guidance, do not run other logic - // between createServerClient and getUser — a stale token here would sign the + // between createServerClient and getClaims — a stale token here would sign the // user out on the next request. - await supabase.auth.getUser(); + await supabase.auth.getClaims(); return withCsp(response); } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 44f0b66f7..9d6787aba 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-17T11:30:04.370Z", + "generated_at": "2026-07-17T11:56:55.778Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "59ee18d0723774416b931bea9f4fef96fa699eb99ba61d218b6870a621dde649", - "replay_seconds": 45, + "schema_sha256": "35b7898f4c61cc49c43d9720526b2ca0742750bc508a42ca163581318786ee67", + "replay_seconds": 55, "snapshot": { "views": [ { @@ -2228,6 +2228,34 @@ "rls_forced": false, "rls_enabled": true }, + { + "acl": [ + "postgres=arwdDxtm/postgres", + "service_role=arwdDxtm/postgres" + ], + "name": "document_title_words", + "columns": [ + { + "name": "document_id", + "type": "uuid", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + }, + { + "name": "word", + "type": "text", + "default": null, + "identity": "", + "not_null": true, + "generated": "" + } + ], + "reloptions": null, + "rls_forced": false, + "rls_enabled": true + }, { "acl": [ "postgres=arwdDxtm/postgres", @@ -5160,6 +5188,24 @@ "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", + "table": "document_title_words", + "def_hash": "8da6b7cecd94a940059ae6052c585a50" + }, + { + "def": "CREATE INDEX document_title_words_word_trgm_idx ON public.document_title_words USING gin (word extensions.gin_trgm_ops)", + "name": "document_title_words_word_trgm_idx", + "table": "document_title_words", + "def_hash": "e7ea304b143482a8aeac075dd5f7484d" + }, { "def": "CREATE INDEX documents_import_batch_idx ON public.documents USING btree (import_batch_id)", "name": "documents_import_batch_idx", @@ -5208,6 +5254,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", @@ -5412,6 +5464,12 @@ "table": "rag_aliases", "def_hash": "de4c954f6260a846824662bdc55ea171" }, + { + "def": "CREATE INDEX rag_aliases_canonical_trgm_idx ON public.rag_aliases USING gin (lower(canonical) extensions.gin_trgm_ops)", + "name": "rag_aliases_canonical_trgm_idx", + "table": "rag_aliases", + "def_hash": "e88844a9cb30e4980f1c92a94ae0b24a" + }, { "def": "CREATE INDEX rag_aliases_owner_enabled_idx ON public.rag_aliases USING btree (owner_id, enabled)", "name": "rag_aliases_owner_enabled_idx", @@ -6118,11 +6176,21 @@ } ], "triggers": [ + { + "def": "CREATE TRIGGER clinical_registry_records_delete_cleanup AFTER DELETE ON public.clinical_registry_records FOR EACH ROW EXECUTE FUNCTION public.cleanup_registry_corpus_document()", + "name": "clinical_registry_records_delete_cleanup", + "table": "clinical_registry_records" + }, { "def": "CREATE TRIGGER clinical_registry_records_updated_at BEFORE UPDATE ON public.clinical_registry_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "clinical_registry_records_updated_at", "table": "clinical_registry_records" }, + { + "def": "CREATE TRIGGER differential_records_delete_cleanup AFTER DELETE ON public.differential_records FOR EACH ROW EXECUTE FUNCTION public.cleanup_registry_corpus_document()", + "name": "differential_records_delete_cleanup", + "table": "differential_records" + }, { "def": "CREATE TRIGGER differential_records_updated_at BEFORE UPDATE ON public.differential_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "differential_records_updated_at", @@ -6148,6 +6216,11 @@ "name": "document_sections_updated_at", "table": "document_sections" }, + { + "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", @@ -6168,6 +6241,11 @@ "name": "ingestion_jobs_updated_at", "table": "ingestion_jobs" }, + { + "def": "CREATE TRIGGER medication_records_delete_cleanup AFTER DELETE ON public.medication_records FOR EACH ROW EXECUTE FUNCTION public.cleanup_registry_corpus_document()", + "name": "medication_records_delete_cleanup", + "table": "medication_records" + }, { "def": "CREATE TRIGGER medication_records_updated_at BEFORE UPDATE ON public.medication_records FOR EACH ROW EXECUTE FUNCTION public.set_updated_at()", "name": "medication_records_updated_at", @@ -6261,6 +6339,14 @@ "def_hash": "4ee148dcab28d6bbe10cfa542213f0f6", "signature": "public.cleanup_abandoned_document_index_generations(uuid,integer,boolean)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "7104ee6e947fcca68fdebdf2fa878339", + "signature": "public.cleanup_registry_corpus_document()" + }, { "acl": [ "postgres=X/postgres", @@ -6316,6 +6402,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": "de3edcc4dd76c950759f24589b0f3a9b", + "signature": "public.consume_summary_rate_limits_atomic(uuid,text,integer,integer,integer,integer,integer,integer)" + }, { "acl": [ "postgres=X/postgres", @@ -6337,7 +6431,7 @@ "postgres=X/postgres", "service_role=X/postgres" ], - "def_hash": "a5ebdd1ec14008cdf0c8601a32b4d24c", + "def_hash": "9e3c5593116fe03ee59b701a0a087ef3", "signature": "public.correct_clinical_query_terms(text,real)" }, { @@ -6813,6 +6907,14 @@ "def_hash": "3b5c6084651ca0f8fd8e069106e8f7dd", "signature": "public.stamp_document_deep_memory_version(uuid,text)" }, + { + "acl": [ + "postgres=X/postgres", + "service_role=X/postgres" + ], + "def_hash": "6b51ece12494d136b39977d8532c013c", + "signature": "public.sync_document_title_words()" + }, { "acl": [ "postgres=X/postgres", @@ -7289,6 +7391,26 @@ "name": "document_table_facts_source_image_id_fkey", "table": "document_table_facts" }, + { + "def": "FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE CASCADE", + "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/20260717130000_registry_projection_cleanup.sql b/supabase/migrations/20260717130000_registry_projection_cleanup.sql new file mode 100644 index 000000000..5b3f926c7 --- /dev/null +++ b/supabase/migrations/20260717130000_registry_projection_cleanup.sql @@ -0,0 +1,51 @@ +set lock_timeout = '5s'; +set statement_timeout = '60s'; + +-- Operators rolling this out on a busy database may pre-create this exact +-- index concurrently outside the migration transaction. The IF NOT EXISTS +-- below then becomes a no-op during the eventual authorized migration apply. +create index if not exists documents_registry_projection_lookup_idx + on public.documents ( + (metadata->>'registry_record_kind'), + (metadata->>'registry_record_id') + ) + where metadata->>'source_kind' = 'registry_record'; + +create or replace function public.cleanup_registry_corpus_document() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + delete from public.documents + where metadata->>'source_kind' = 'registry_record' + and metadata->>'registry_record_kind' = case tg_table_name + when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind' + when 'medication_records' then 'medication' + when 'differential_records' then 'differential' + else null + end + and metadata->>'registry_record_id' = old.id::text; + + return old; +end; +$$; + +revoke execute on function public.cleanup_registry_corpus_document() + from public, anon, authenticated, service_role; + +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(); diff --git a/supabase/migrations/20260717131000_public_title_corrector.sql b/supabase/migrations/20260717131000_public_title_corrector.sql new file mode 100644 index 000000000..43aa53c6e --- /dev/null +++ b/supabase/migrations/20260717131000_public_title_corrector.sql @@ -0,0 +1,149 @@ +set lock_timeout = '5s'; +set statement_timeout = '60s'; + +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), + constraint document_title_words_word_length check (length(word) between 4 and 40), + constraint document_title_words_lowercase check (word = lower(word)) +); + +create index if not exists document_title_words_word_trgm_idx + on public.document_title_words using gin (word extensions.gin_trgm_ops); + +create index if not exists document_title_words_document_id_idx + on public.document_title_words (document_id); + +create index if not exists rag_aliases_canonical_trgm_idx + on public.rag_aliases using gin (lower(canonical) extensions.gin_trgm_ops); + +alter table public.document_title_words enable row level security; +revoke all on table public.document_title_words from public, anon, authenticated; +grant select, insert, update, delete on table public.document_title_words to service_role; + +insert into public.document_title_words (word, document_id) +select distinct lower(title_word), d.id +from public.documents d +cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word +where d.owner_id is null and d.status = 'indexed' + and length(title_word) between 4 and 40 +on conflict do nothing; + +create or replace function public.sync_document_title_words() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if tg_op <> 'INSERT' then + delete from public.document_title_words where document_id = old.id; + end if; + + if tg_op <> 'DELETE' and new.owner_id is null and new.status = 'indexed' then + insert into public.document_title_words (word, document_id) + select distinct lower(title_word), new.id + from pg_catalog.unnest(pg_catalog.regexp_split_to_array(lower(new.title), '[^a-z]+')) as title_word + where length(title_word) between 4 and 40 + on conflict do nothing; + end if; + + return null; +end; +$$; + +revoke execute on function public.sync_document_title_words() + from public, anon, authenticated, service_role; + +drop trigger if exists documents_sync_title_words on public.documents; +create trigger documents_sync_title_words + after insert or update of title, status, owner_id or delete on public.documents + for each row execute function public.sync_document_title_words(); + +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 = pg_catalog, extensions +as $$ +declare + 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; + + 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 owner_id is null + 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 owner_id is null + 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; diff --git a/supabase/migrations/20260717132000_atomic_summary_rate_limits.sql b/supabase/migrations/20260717132000_atomic_summary_rate_limits.sql new file mode 100644 index 000000000..cf2b5dbf2 --- /dev/null +++ b/supabase/migrations/20260717132000_atomic_summary_rate_limits.sql @@ -0,0 +1,169 @@ +set lock_timeout = '5s'; +set statement_timeout = '30s'; + +create or replace function public.consume_summary_rate_limits_atomic( + p_owner_id uuid, + p_subject_key text, + p_answer_limit integer, + p_answer_window_seconds integer, + p_summary_limit integer, + p_summary_window_seconds integer, + p_global_answer_limit integer, + p_global_answer_window_seconds integer +) +returns table ( + bucket text, + limited boolean, + limit_value integer, + remaining integer, + retry_after_seconds integer, + reset_at timestamptz +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_now timestamptz := pg_catalog.statement_timestamp(); + v_policy record; + v_count integer; + v_reset_at timestamptz; + v_min_remaining integer := 2147483647; +begin + if (p_owner_id is null) = (p_subject_key is null or pg_catalog.btrim(p_subject_key) = '') then + raise exception 'exactly one owner_id or subject_key is required'; + end if; + if p_answer_limit is null or p_answer_limit < 1 + or p_answer_window_seconds is null or p_answer_window_seconds < 1 + or p_summary_limit is null or p_summary_limit < 1 + or p_summary_window_seconds is null or p_summary_window_seconds < 1 + or p_global_answer_limit is null or p_global_answer_limit < 1 + or p_global_answer_window_seconds is null or p_global_answer_window_seconds < 1 then + raise exception 'limits and windows must be positive'; + end if; + + if p_owner_id is not null then + insert into public.api_rate_limits (owner_id, bucket, window_start, request_count, updated_at) + values + (p_owner_id, 'answer', v_now, 0, v_now), + (p_owner_id, 'document_summarize', v_now, 0, v_now) + on conflict (owner_id, bucket) do nothing; + + -- Acquire every participating row before incrementing, in one stable order. + perform 1 + from public.api_rate_limits as rl + where rl.owner_id = p_owner_id + and rl.bucket in ('answer', 'document_summarize') + order by rl.bucket + for update; + + for v_policy in + select * + from (values + ('answer'::text, 1, p_answer_limit, p_answer_window_seconds), + ('document_summarize'::text, 2, p_summary_limit, p_summary_window_seconds) + ) as policy(bucket, ordinal, limit_value, window_seconds) + order by ordinal + loop + update public.api_rate_limits as rl + set + window_start = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now + else rl.window_start + end, + request_count = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1 + else rl.request_count + 1 + end, + updated_at = v_now + where rl.owner_id = p_owner_id and rl.bucket = v_policy.bucket + returning rl.request_count, + rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) + into v_count, v_reset_at; + + v_min_remaining := least(v_min_remaining, greatest(v_policy.limit_value - v_count, 0)); + if v_count > v_policy.limit_value then + return query select + v_policy.bucket::text, + true, + v_policy.limit_value::integer, + 0, + greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer), + v_reset_at; + return; + end if; + end loop; + else + insert into public.api_rate_limit_subjects (subject_key, bucket, window_start, request_count, updated_at) + values + (p_subject_key, 'answer', v_now, 0, v_now), + ('anon:answer:global', 'answer', v_now, 0, v_now), + (p_subject_key, 'document_summarize', v_now, 0, v_now) + on conflict (subject_key, bucket) do nothing; + + -- Subject and global rows share one stable lexical lock order. + perform 1 + from public.api_rate_limit_subjects as rl + where (rl.subject_key, rl.bucket) in ( + (p_subject_key, 'answer'), + ('anon:answer:global', 'answer'), + (p_subject_key, 'document_summarize') + ) + order by rl.subject_key, rl.bucket + for update; + + for v_policy in + select * + from (values + ('answer'::text, 1, p_subject_key, p_answer_limit, p_answer_window_seconds, 'answer'::text), + ('answer'::text, 2, 'anon:answer:global', p_global_answer_limit, p_global_answer_window_seconds, 'answer'::text), + ('document_summarize'::text, 3, p_subject_key, p_summary_limit, p_summary_window_seconds, 'document_summarize'::text) + ) as policy(bucket, ordinal, subject_key, limit_value, window_seconds, rejection_bucket) + order by ordinal + loop + update public.api_rate_limit_subjects as rl + set + window_start = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now + else rl.window_start + end, + request_count = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1 + else rl.request_count + 1 + end, + updated_at = v_now + where rl.subject_key = v_policy.subject_key and rl.bucket = v_policy.bucket + returning rl.request_count, + rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) + into v_count, v_reset_at; + + v_min_remaining := least(v_min_remaining, greatest(v_policy.limit_value - v_count, 0)); + if v_count > v_policy.limit_value then + return query select + v_policy.rejection_bucket::text, + true, + v_policy.limit_value::integer, + 0, + greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer), + v_reset_at; + return; + end if; + end loop; + end if; + + return query select + null::text, + false, + p_summary_limit, + v_min_remaining, + greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer), + v_reset_at; +end; +$$; + +revoke execute on function public.consume_summary_rate_limits_atomic( + uuid, text, integer, integer, integer, integer, integer, integer +) from public, anon, authenticated; +grant execute on function public.consume_summary_rate_limits_atomic( + uuid, text, integer, integer, integer, integer, integer, integer +) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index 292bfeb9f..2299ea492 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -4981,6 +4981,177 @@ begin end; end $$; +-- Performance remediation: registry projection lifecycle cleanup. +create index if not exists documents_registry_projection_lookup_idx + on public.documents ( + (metadata->>'registry_record_kind'), + (metadata->>'registry_record_id') + ) + where metadata->>'source_kind' = 'registry_record'; + +create or replace function public.cleanup_registry_corpus_document() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + delete from public.documents + where metadata->>'source_kind' = 'registry_record' + and metadata->>'registry_record_kind' = case tg_table_name + when 'clinical_registry_records' then pg_catalog.to_jsonb(old)->>'kind' + when 'medication_records' then 'medication' + when 'differential_records' then 'differential' + else null + end + and metadata->>'registry_record_id' = old.id::text; + return old; +end; +$$; + +revoke execute on function public.cleanup_registry_corpus_document() + from public, anon, authenticated, service_role; + +-- Performance remediation: privacy-preserving indexed title vocabulary. +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), + constraint document_title_words_word_length check (length(word) between 4 and 40), + constraint document_title_words_lowercase check (word = lower(word)) +); + +create index if not exists document_title_words_word_trgm_idx + on public.document_title_words using gin (word extensions.gin_trgm_ops); + +create index if not exists document_title_words_document_id_idx + on public.document_title_words (document_id); +create index if not exists rag_aliases_canonical_trgm_idx + on public.rag_aliases using gin (lower(canonical) extensions.gin_trgm_ops); + +alter table public.document_title_words enable row level security; +revoke all on table public.document_title_words from public, anon, authenticated; +grant select, insert, update, delete on table public.document_title_words to service_role; + +insert into public.document_title_words (word, document_id) +select distinct lower(title_word), d.id +from public.documents d +cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word +where d.owner_id is null and d.status = 'indexed' + and length(title_word) between 4 and 40 +on conflict do nothing; + +create or replace function public.sync_document_title_words() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if tg_op <> 'INSERT' then + delete from public.document_title_words where document_id = old.id; + end if; + if tg_op <> 'DELETE' and new.owner_id is null and new.status = 'indexed' then + insert into public.document_title_words (word, document_id) + select distinct lower(title_word), new.id + from pg_catalog.unnest(pg_catalog.regexp_split_to_array(lower(new.title), '[^a-z]+')) as title_word + where length(title_word) between 4 and 40 + on conflict do nothing; + end if; + return null; +end; +$$; + +revoke execute on function public.sync_document_title_words() + from public, anon, authenticated, service_role; +drop trigger if exists documents_sync_title_words on public.documents; +create trigger documents_sync_title_words + after insert or update of title, status, owner_id or delete on public.documents + for each row execute function public.sync_document_title_words(); + +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 = pg_catalog, extensions +as $$ +declare + 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; + 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 owner_id is null + 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 owner_id is null + 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; + revoke usage on schema public from anon; grant usage on schema public to authenticated, service_role; @@ -7525,3 +7696,171 @@ begin or public.retrieval_owner_matches_v2('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', true) then raise exception 'retrieval_owner_matches_v2 truth table failed'; end if; 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(); + +create or replace function public.consume_summary_rate_limits_atomic( + p_owner_id uuid, + p_subject_key text, + p_answer_limit integer, + p_answer_window_seconds integer, + p_summary_limit integer, + p_summary_window_seconds integer, + p_global_answer_limit integer, + p_global_answer_window_seconds integer +) +returns table ( + bucket text, + limited boolean, + limit_value integer, + remaining integer, + retry_after_seconds integer, + reset_at timestamptz +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_now timestamptz := pg_catalog.statement_timestamp(); + v_policy record; + v_count integer; + v_reset_at timestamptz; + v_min_remaining integer := 2147483647; +begin + if (p_owner_id is null) = (p_subject_key is null or pg_catalog.btrim(p_subject_key) = '') then + raise exception 'exactly one owner_id or subject_key is required'; + end if; + if p_answer_limit is null or p_answer_limit < 1 + or p_answer_window_seconds is null or p_answer_window_seconds < 1 + or p_summary_limit is null or p_summary_limit < 1 + or p_summary_window_seconds is null or p_summary_window_seconds < 1 + or p_global_answer_limit is null or p_global_answer_limit < 1 + or p_global_answer_window_seconds is null or p_global_answer_window_seconds < 1 then + raise exception 'limits and windows must be positive'; + end if; + + if p_owner_id is not null then + insert into public.api_rate_limits (owner_id, bucket, window_start, request_count, updated_at) + values + (p_owner_id, 'answer', v_now, 0, v_now), + (p_owner_id, 'document_summarize', v_now, 0, v_now) + on conflict (owner_id, bucket) do nothing; + perform 1 + from public.api_rate_limits as rl + where rl.owner_id = p_owner_id and rl.bucket in ('answer', 'document_summarize') + order by rl.bucket + for update; + for v_policy in + select * from (values + ('answer'::text, 1, p_answer_limit, p_answer_window_seconds), + ('document_summarize'::text, 2, p_summary_limit, p_summary_window_seconds) + ) as policy(bucket, ordinal, limit_value, window_seconds) + order by ordinal + loop + update public.api_rate_limits as rl + set + window_start = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now + else rl.window_start + end, + request_count = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1 + else rl.request_count + 1 + end, + updated_at = v_now + where rl.owner_id = p_owner_id and rl.bucket = v_policy.bucket + returning rl.request_count, + rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) + into v_count, v_reset_at; + v_min_remaining := least(v_min_remaining, greatest(v_policy.limit_value - v_count, 0)); + if v_count > v_policy.limit_value then + return query select + v_policy.bucket::text, + true, + v_policy.limit_value::integer, + 0, + greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer), + v_reset_at; + return; + end if; + end loop; + else + insert into public.api_rate_limit_subjects (subject_key, bucket, window_start, request_count, updated_at) + values + (p_subject_key, 'answer', v_now, 0, v_now), + ('anon:answer:global', 'answer', v_now, 0, v_now), + (p_subject_key, 'document_summarize', v_now, 0, v_now) + on conflict (subject_key, bucket) do nothing; + perform 1 + from public.api_rate_limit_subjects as rl + where (rl.subject_key, rl.bucket) in ( + (p_subject_key, 'answer'), + ('anon:answer:global', 'answer'), + (p_subject_key, 'document_summarize') + ) + order by rl.subject_key, rl.bucket + for update; + for v_policy in + select * from (values + ('answer'::text, 1, p_subject_key, p_answer_limit, p_answer_window_seconds, 'answer'::text), + ('answer'::text, 2, 'anon:answer:global', p_global_answer_limit, p_global_answer_window_seconds, 'answer'::text), + ('document_summarize'::text, 3, p_subject_key, p_summary_limit, p_summary_window_seconds, 'document_summarize'::text) + ) as policy(bucket, ordinal, subject_key, limit_value, window_seconds, rejection_bucket) + order by ordinal + loop + update public.api_rate_limit_subjects as rl + set + window_start = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then v_now + else rl.window_start + end, + request_count = case + when rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) <= v_now then 1 + else rl.request_count + 1 + end, + updated_at = v_now + where rl.subject_key = v_policy.subject_key and rl.bucket = v_policy.bucket + returning rl.request_count, + rl.window_start + pg_catalog.make_interval(secs => v_policy.window_seconds) + into v_count, v_reset_at; + v_min_remaining := least(v_min_remaining, greatest(v_policy.limit_value - v_count, 0)); + if v_count > v_policy.limit_value then + return query select + v_policy.rejection_bucket::text, + true, + v_policy.limit_value::integer, + 0, + greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer), + v_reset_at; + return; + end if; + end loop; + end if; + return query select + null::text, + false, + p_summary_limit, + v_min_remaining, + greatest(1, pg_catalog.ceil(extract(epoch from (v_reset_at - v_now)))::integer), + v_reset_at; +end; +$$; + +revoke execute on function public.consume_summary_rate_limits_atomic( + uuid, text, integer, integer, integer, integer, integer, integer +) from public, anon, authenticated; +grant execute on function public.consume_summary_rate_limits_atomic( + uuid, text, integer, integer, integer, integer, integer, integer +) to service_role; diff --git a/tests/answer-client-payload.test.ts b/tests/answer-client-payload.test.ts index 1cdc0f07a..78fda5eb0 100644 --- a/tests/answer-client-payload.test.ts +++ b/tests/answer-client-payload.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { toClientAnswerPayload } from "@/lib/answer-client-payload"; +import { buildGovernedAnswerClientResponse, buildGovernedDemoAnswerClientResponse } from "@/lib/answer-response"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import type { RagAnswer, SearchResult } from "@/lib/types"; @@ -32,6 +33,23 @@ function answerWith(sources: SearchResult[]): Pick { } describe("toClientAnswerPayload", () => { + it("governs empty-source real and demo answers without requiring source fields", () => { + const answer = { + answer: "No source details.", + grounded: true, + confidence: "high", + citations: [], + sources: [], + } as RagAnswer; + + expect(buildGovernedAnswerClientResponse(answer).payload).toMatchObject({ sources: [], safetyWarnings: [] }); + expect(buildGovernedDemoAnswerClientResponse(answer)).toMatchObject({ + sources: [], + safetyWarnings: [], + demoMode: true, + }); + }); + it("drops server-only per-source fields the client never renders", () => { const trimmed = toClientAnswerPayload(answerWith([fullSource()])).sources![0]; expect(trimmed.adjacent_context).toBeUndefined(); @@ -39,6 +57,22 @@ describe("toClientAnswerPayload", () => { expect(trimmed.table_facts).toBeUndefined(); expect(trimmed.index_unit).toBeUndefined(); expect(trimmed.document_summary).toBeUndefined(); + expect(trimmed.images).toEqual([]); + }); + + it("does not serialize bulky source image objects", () => { + const image = { + id: "image-1", + storage_path: "private/source/page-4.png", + metadata: { raw: "x".repeat(8_000) }, + table_markdown: `| heading |\n| --- |\n| ${"cell ".repeat(1_000)} |`, + } as never; + const source = fullSource({ image_ids: ["image-1"], images: [image] }); + const trimmed = toClientAnswerPayload(answerWith([source])).sources![0]; + + expect(trimmed.image_ids).toEqual(["image-1"]); + expect(trimmed.images).toEqual([]); + expect(JSON.stringify(trimmed)).not.toContain("private/source/page-4.png"); }); it("does not pass unclassified runtime fields through the route boundary", () => { @@ -54,27 +88,32 @@ describe("toClientAnswerPayload", () => { expect(trimmed.id).toBe("chunk-1"); expect(trimmed.title).toBe("Clozapine monitoring guideline"); expect(trimmed.retrieval_synopsis).toBe("FBC weekly for 18 weeks, then monthly."); + expect(trimmed.content).toBe("FBC weekly for 18 weeks, then monthly."); expect(trimmed.similarity).toBe(0.82); expect(trimmed.source_metadata).toEqual({ document_status: "current" }); expect(trimmed.page_number).toBe(4); }); - it("preserves full source content so client-side safety scanning cannot miss later warnings", () => { + it("derives safety warnings before replacing full source content with the rendered snippet", () => { const source = fullSource({ content: `${"Routine context. ".repeat(60)}Contraindicated in severe disease.` }); - const payload = toClientAnswerPayload({ + const response = buildGovernedAnswerClientResponse({ answer: "Review the source.", grounded: true, confidence: "medium", citations: [], sources: [source], } as RagAnswer); + const payload = response.payload; - expect(payload.sources![0].content).toBe(source.content); + expect(payload.sources![0].content).toBe(source.retrieval_synopsis); + expect(payload.sources![0].content.length).toBeLessThan(source.content.length); + expect(payload.safetyWarnings).toHaveLength(1); + expect(payload.safetyWarnings![0].citation).not.toHaveProperty("source_metadata"); expect(extractSafetyFindings(payload)).toHaveLength(1); }); it("leaves short content untouched", () => { - const short = fullSource({ content: "Short snippet." }); + const short = fullSource({ content: "Short snippet.", retrieval_synopsis: undefined }); expect(toClientAnswerPayload(answerWith([short])).sources![0].content).toBe("Short snippet."); }); diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts index a0ce75871..76c1e0192 100644 --- a/tests/api-rate-limit-fallback.test.ts +++ b/tests/api-rate-limit-fallback.test.ts @@ -105,6 +105,56 @@ describe("paid anonymous answer limits", () => { }); }); +describe("atomic streamed-summary limits", () => { + it("applies anonymous caller, global-answer, and summary policies in one RPC", async () => { + const { consumeSummaryRateLimits } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async () => ({ + data: [ + { + bucket: null, + limited: false, + limit_value: 12, + remaining: 5, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + })); + + const decision = await consumeSummaryRateLimits({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + }); + + expect(decision).toMatchObject({ bucket: null, rateLimit: { limited: false, limit: 12, remaining: 5 } }); + expect(rpc).toHaveBeenCalledTimes(1); + expect(rpc).toHaveBeenCalledWith("consume_summary_rate_limits_atomic", { + p_owner_id: null, + p_subject_key: "anon:caller", + p_answer_limit: 6, + p_answer_window_seconds: 60, + p_summary_limit: 12, + p_summary_window_seconds: 60, + p_global_answer_limit: 30, + p_global_answer_window_seconds: 60, + }); + }); + + it("fails closed when the atomic RPC is unavailable", async () => { + const { ApiRateLimitUnavailableError, consumeSummaryRateLimits } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async () => ({ data: null, error: { code: "PGRST202", message: "missing RPC" } })); + + await expect( + consumeSummaryRateLimits({ + supabase: { rpc } as never, + subject: { kind: "owner", ownerId: "owner-1" }, + }), + ).rejects.toBeInstanceOf(ApiRateLimitUnavailableError); + expect(rpc).toHaveBeenCalledTimes(1); + }); +}); + describe("document_upload fail-closed limiter", () => { it("fails closed (does not fall back to per-instance memory) when the durable limiter is unavailable", async () => { vi.stubEnv("NODE_ENV", "production"); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 2a0becd58..41743422c 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -101,6 +101,11 @@ class QueryBuilder implements PromiseLike { return this; } + abortSignal(signal: AbortSignal) { + void signal; + return this; + } + maybeSingle() { this.call.maybeSingle = true; return this.resolve(); diff --git a/tests/bundle-budget.test.ts b/tests/bundle-budget.test.ts index e8831f48f..f15d9d751 100644 --- a/tests/bundle-budget.test.ts +++ b/tests/bundle-budget.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { compareToBudget, measureChunks } from "../scripts/check-bundle-budget.mjs"; +import { + compareToBudget, + findFixtureSnapshotsInChunks, + initialDashboardChunkNames, + measureChunks, +} from "../scripts/check-bundle-budget.mjs"; const buf = (n: number) => Buffer.alloc(n, "a"); // highly compressible; gzip < raw @@ -46,3 +51,51 @@ describe("compareToBudget", () => { expect(v.status).toBe("ok"); }); }); + +describe("initial dashboard fixture boundary", () => { + it("resolves root layout, page, and shared chunks without dynamic route chunks", () => { + expect( + initialDashboardChunkNames({ + rootMainFiles: ["static/chunks/main.js"], + pages: { + "/layout": ["static/chunks/layout.js", "static/css/layout.css"], + "/page": ["static/chunks/page.js"], + "/documents/[id]/page": ["static/chunks/document-viewer.js"], + }, + }), + ).toEqual(["main.js", "layout.js", "page.js"]); + }); + + it("resolves Next 16 root-page chunks from the client-reference manifest", () => { + expect( + initialDashboardChunkNames( + { + rootMainFiles: ["static/chunks/main-app.js"], + pages: { "/_app": [] }, + }, + { + clientModules: { + home: { chunks: ["1234", "static/chunks/1234-home.js"] }, + lazy: { chunks: [] }, + }, + }, + ), + ).toEqual(["main-app.js", "1234-home.js"]); + }); + + it("detects complete fixture marker groups in initial chunks", () => { + const violations = findFixtureSnapshotsInChunks([ + { + name: "page.js", + buffer: Buffer.from("transport-crisis-form extension-transport-order detention-examination-movement"), + }, + ]); + expect(violations).toEqual(["forms fixture catalogue"]); + }); + + it("does not flag an isolated UI string as a serialized fixture", () => { + expect( + findFixtureSnapshotsInChunks([{ name: "page.js", buffer: Buffer.from("Try first episode psychosis") }]), + ).toEqual([]); + }); +}); diff --git a/tests/client-performance-boundaries.test.ts b/tests/client-performance-boundaries.test.ts new file mode 100644 index 000000000..f3df3436d --- /dev/null +++ b/tests/client-performance-boundaries.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +function source(path: string) { + return readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); +} + +describe("fixture-free client performance boundaries", () => { + it("keeps service and form rankers independent of server fixture catalogues", () => { + const serviceRanker = source("src/lib/service-ranker.ts"); + const formRanker = source("src/lib/form-ranker.ts"); + expect(serviceRanker).not.toMatch(/registry-fixtures|services-snapshot|defaultServiceRecords/); + expect(formRanker).not.toMatch(/@\/lib\/forms|formRecords|registry-fixtures/); + expect(source("src/lib/services.ts")).toContain('export * from "@/lib/service-ranker"'); + expect(source("src/lib/forms.ts")).toContain('export * from "@/lib/form-ranker"'); + }); + + it("keeps differential result composition independent of the generated snapshot", () => { + const composition = source("src/lib/differential-search-composition.ts"); + expect(composition).not.toMatch(/differential-fixtures|differentials-snapshot|loadDifferentialSnapshot/); + const dashboard = source("src/components/clinical-dashboard/differentials-home.tsx"); + expect(dashboard).toContain('from "@/lib/differential-search-composition"'); + expect(dashboard).not.toContain('from "@/lib/differentials"'); + }); + + it("keeps initial dashboard rankers on fixture-free entry points", () => { + const dashboard = source("src/components/ClinicalDashboard.tsx"); + expect(dashboard).toContain('from "@/lib/form-ranker"'); + expect(dashboard).toContain('from "@/lib/service-ranker"'); + expect(source("src/lib/cross-mode-links.ts")).not.toMatch(/@\/lib\/(forms|services)"/); + }); + + it("loads administration data only after its source surface opens", () => { + const dashboard = source("src/components/ClinicalDashboard.tsx"); + expect(dashboard).toContain("includeSetup: true, includeDashboardData: false"); + expect(dashboard).toContain("dashboardDataSurfaceVisible && !dashboardDataLoadedRef.current"); + expect(dashboard).toContain("administrationSurfaceVisible && !administrationDataLoadedRef.current"); + expect(dashboard).toContain("userStartedIngestion && activeIndexingWork"); + }); + + it("retains PDF.js as an on-demand import", () => { + const pdfViewer = source("src/components/document-viewer/pdf-canvas-viewer.tsx"); + expect(pdfViewer).toContain('await import("pdfjs-dist")'); + expect(pdfViewer).not.toMatch(/^import(?! type\b).* from "pdfjs-dist";/m); + }); +}); diff --git a/tests/differentials-route.test.ts b/tests/differentials-route.test.ts index 508e69cda..57b450169 100644 --- a/tests/differentials-route.test.ts +++ b/tests/differentials-route.test.ts @@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const token = "valid-token"; +const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"; + +function expectPublicFixtureCache(response: Response) { + expect(response.headers.get("cache-control")).toBe(publicFixtureCacheControl); + expect(response.headers.get("vary")).toBe("Cookie, Authorization"); +} + +function expectPrivateCache(response: Response) { + expect(response.headers.get("cache-control")).toBe("private, no-store"); +} type QueryError = { message: string }; type QueryResult = { data: unknown; error: QueryError | null }; @@ -185,6 +195,7 @@ describe("differentials API routes", () => { }; expect(response.status).toBe(200); + expectPrivateCache(response); expect(payload.detailContext?.knownRelatedSlugs).toEqual([]); expect(payload.detailContext?.overlapLinks).toEqual({}); expect(payload.detailContext?.comparePresentation).toBeNull(); @@ -205,6 +216,7 @@ describe("differentials API routes", () => { }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.demoMode).toBe(true); expect(payload.record?.slug).toBe("delirium"); expect(payload.detailContext?.comparePresentation?.slug).toBe("acute-confusion-encephalopathy"); @@ -227,6 +239,7 @@ describe("differentials API routes", () => { }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.demoMode).toBe(true); expect(payload.workflow?.id).toBe("acute-confusion-encephalopathy"); expect(payload.candidates?.length).toBeGreaterThan(0); @@ -247,6 +260,7 @@ describe("differentials API routes", () => { }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.demoMode).toBe(true); expect((payload.total ?? 0) > 100).toBe(true); expect(payload.records?.length).toBeGreaterThan(0); diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts new file mode 100644 index 000000000..33fd096ea --- /dev/null +++ b/tests/document-detail-performance.test.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("document detail loading contract", () => { + it("uses one server-only authorized loader from both the route and page", () => { + const loader = source("src/lib/document-detail.ts"); + const route = source("src/app/api/documents/[id]/route.ts"); + const page = source("src/app/documents/[id]/page.tsx"); + + expect(loader).toContain('import "server-only"'); + expect(loader).toContain("loadAuthorizedDocumentDetail"); + expect(route).toContain("loadAuthorizedDocumentDetail"); + expect(page).toContain("loadAuthorizedDocumentDetail"); + expect(page).toContain("initialDetail={initialDetail}"); + expect(page).toContain("initialError={initialError}"); + expect(page).toContain('key={`${id}:${initialPage}:${query.chunk ?? ""}`}'); + }); + + it("supports document and window asset scopes and starts independent detail reads together", () => { + const loader = source("src/lib/document-detail.ts"); + + expect(loader).toContain('assetScope: "document" | "window"'); + expect(loader).toContain("Promise.all(["); + expect(loader).toContain("pagesRequest"); + expect(loader).toContain("chunksRequest"); + expect(loader).toContain("imagesRequest"); + expect(loader).toContain("tableFactsRequest"); + expect(loader).toContain("labelsRequest"); + expect(loader).toContain("summaryRequest"); + expect(loader).toContain("selectedImageIds(selectedChunk)"); + expect(loader).toContain("imagesRequest.or(imageWindowFilter"); + expect(loader).toContain("and(image_type.neq.logo_decorative,or(searchable.eq.true,source_kind.eq.table_crop)"); + expect(loader).toContain("id.in.(${imageIds.join"); + expect(loader).toContain("tableFactsRequest.or(tableFactWindowFilter"); + expect(loader).toContain("page_number.is.null"); + }); + + it("cancels every database phase and projects only viewer fields", () => { + const loader = source("src/lib/document-detail.ts"); + const abortAttachments = loader.match(/\.abortSignal\(args\.request\.signal\)/g) ?? []; + + expect(abortAttachments).toHaveLength(8); + expect(loader).toContain("args.request.signal.throwIfAborted()"); + expect(loader).not.toContain('.select("*")'); + expect(loader).toContain("documentDetailProjection"); + expect(loader).toContain("tableFactDetailProjection"); + expect(loader).toContain("map(withoutMetadata)"); + expect(loader).toContain("map(withTableFactReviewMetadata)"); + expect(loader).toContain("map(withDocumentLabelReviewMetadata)"); + expect(loader).toContain("isHiddenDocumentLabel"); + }); + + it("returns explicit demo, scope, and request-window metadata", () => { + const loader = source("src/lib/document-detail.ts"); + + expect(loader).toContain("demoMode:"); + expect(loader).toContain("assetScope:"); + expect(loader).toContain("window:"); + expect(loader).toContain("requestedPage:"); + expect(loader).toContain("effectivePage:"); + }); +}); + +describe("document viewer latency guards", () => { + it("server-prerenders the viewer without a setup-status round trip", () => { + const lazy = source("src/components/document-viewer-lazy.tsx"); + const viewer = source("src/components/DocumentViewer.tsx"); + + expect(lazy).not.toContain("ssr: false"); + expect(viewer).not.toContain("/api/setup-status"); + }); + + it("loads window-scoped navigation details and renders one indexed-text panel", () => { + const viewer = source("src/components/DocumentViewer.tsx"); + const panelInstances = viewer.match(/ { + const viewer = source("src/components/DocumentViewer.tsx"); + + expect(viewer).toContain("openSourcePreview"); + expect(viewer).toContain("openSourceDownload"); + expect(viewer).toContain("downloadActionRef"); + expect(viewer.indexOf("?download=true")).toBeGreaterThan(viewer.indexOf("openSourceDownload")); + expect(viewer).not.toContain("fetchSignedUrlPair"); + }); +}); diff --git a/tests/document-enrichment-visual-counts.test.ts b/tests/document-enrichment-visual-counts.test.ts new file mode 100644 index 000000000..fe0d4fa40 --- /dev/null +++ b/tests/document-enrichment-visual-counts.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fetchRelatedDocumentMetadata, fetchRelatedDocuments } from "../src/lib/document-enrichment"; +import type { SearchResult } from "../src/lib/types"; + +const results = [ + { + id: "chunk-1", + document_id: "document-1", + title: "Clozapine monitoring", + file_name: "clozapine.pdf", + page_number: 2, + chunk_index: 0, + section_heading: "Monitoring", + content: "Clozapine monitoring guidance.", + image_ids: [], + similarity: 0.9, + hybrid_score: 0.92, + images: [], + }, +] as SearchResult[]; + +function metadataRpc() { + return vi.fn(async () => ({ + data: [{ document_id: "document-1", labels: [], summary: "Monitoring guidance." }], + error: null, + })); +} + +describe("related-document visual counts", () => { + it("skips the visual-count query when includeVisualCounts is false", async () => { + const from = vi.fn(() => { + throw new Error("visual-count query should not run"); + }); + const supabase = { rpc: metadataRpc(), from }; + + const related = await fetchRelatedDocuments({ + supabase: supabase as never, + query: "clozapine", + results, + includeVisualCounts: false, + }); + + expect(from).not.toHaveBeenCalled(); + expect(related[0]).toMatchObject({ document_id: "document-1", image_count: 0, table_count: 0 }); + }); + + it("keeps visual counts enabled by default", async () => { + const query = { + select: vi.fn(), + in: vi.fn(), + neq: vi.fn(async () => ({ + data: [ + { + document_id: "document-1", + source_kind: "table_crop", + searchable: true, + image_type: "table", + clinical_relevance_score: 0.9, + metadata: { clinical_use_class: "clinical_evidence" }, + }, + ], + error: null, + })), + }; + query.select.mockReturnValue(query); + query.in.mockReturnValue(query); + const from = vi.fn(() => query); + const supabase = { rpc: metadataRpc(), from }; + + const related = await fetchRelatedDocuments({ + supabase: supabase as never, + query: "clozapine", + results, + }); + + expect(from).toHaveBeenCalledWith("document_images"); + expect(related[0]).toMatchObject({ document_id: "document-1", image_count: 1, table_count: 1 }); + }); + + it("attaches the caller signal to metadata RPC builders", async () => { + const controller = new AbortController(); + const abortSignal = vi.fn(async () => ({ + data: [{ document_id: "document-1", labels: [], summary: null }], + error: null, + })); + const supabase = { rpc: vi.fn(() => ({ abortSignal })) }; + + await fetchRelatedDocumentMetadata({ + supabase: supabase as never, + documentIds: ["document-1"], + signal: controller.signal, + }); + + expect(abortSignal).toHaveBeenCalledWith(controller.signal); + }); +}); diff --git a/tests/fixture-response-cache.test.ts b/tests/fixture-response-cache.test.ts new file mode 100644 index 000000000..d3055559a --- /dev/null +++ b/tests/fixture-response-cache.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { fixtureResponseHeaders } from "../src/lib/fixture-response-cache"; + +const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"; + +describe("fixture response cache policy", () => { + it("publicly caches only conclusively anonymous fixture responses", () => { + const anonymous = fixtureResponseHeaders(new Request("http://localhost/api/fixture"), { fixture: true }); + const bearer = fixtureResponseHeaders( + new Request("http://localhost/api/fixture", { headers: { Authorization: "Bearer token" } }), + { fixture: true }, + ); + const sessionCookie = fixtureResponseHeaders( + new Request("http://localhost/api/fixture", { headers: { Cookie: "sb-project-auth-token=opaque" } }), + { fixture: true }, + ); + + expect(anonymous.get("Cache-Control")).toBe(publicFixtureCacheControl); + expect(anonymous.get("Vary")).toBe("Cookie, Authorization"); + expect(bearer.get("Cache-Control")).toBe("private, no-store"); + expect(sessionCookie.get("Cache-Control")).toBe("private, no-store"); + }); + + it("keeps live values private and merges existing Vary values without duplicates", () => { + const headers = fixtureResponseHeaders(new Request("http://localhost/api/live"), { + headers: { Vary: "Accept-Encoding, cookie" }, + }); + + expect(headers.get("Cache-Control")).toBe("private, no-store"); + expect(headers.get("Vary")).toBe("Accept-Encoding, cookie, Authorization"); + }); +}); diff --git a/tests/medications-route.test.ts b/tests/medications-route.test.ts index 2b30fb779..7d9aa281c 100644 --- a/tests/medications-route.test.ts +++ b/tests/medications-route.test.ts @@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const token = "valid-token"; +const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"; + +function expectPublicFixtureCache(response: Response) { + expect(response.headers.get("cache-control")).toBe(publicFixtureCacheControl); + expect(response.headers.get("vary")).toBe("Cookie, Authorization"); +} + +function expectPrivateCache(response: Response) { + expect(response.headers.get("cache-control")).toBe("private, no-store"); +} const recordId = "11111111-1111-4111-8111-111111111111"; type QueryError = { message: string }; @@ -185,6 +195,7 @@ describe("medications API", () => { const payload = (await response.json()) as { records: Array<{ slug: string }>; demoMode?: boolean }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.demoMode).toBe(true); expect(payload.records.some((record) => record.slug === "acamprosate")).toBe(true); expect(client.from).not.toHaveBeenCalled(); @@ -224,6 +235,7 @@ describe("medications API", () => { }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.publicAccess).toBe(true); expect(payload.records.some((record) => record.slug === "acamprosate")).toBe(true); expect(payload.matches?.[0]?.medication.slug).toBe("acamprosate"); @@ -258,6 +270,7 @@ describe("medications API", () => { }; expect(response.status).toBe(200); + expectPrivateCache(response); expect(payload.records[0]?.slug).toBe("acamprosate"); expect(payload.matches?.[0]?.medication.slug).toBe("acamprosate"); expect(client.calls.some((call) => call.table === "medication_records")).toBe(true); @@ -278,6 +291,7 @@ describe("medications API", () => { }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.publicAccess).toBe(true); expect(payload.record.slug).toBe("acamprosate"); expect(payload.record.name).toBe("Acamprosate"); diff --git a/tests/owner-catalogue-cache.test.ts b/tests/owner-catalogue-cache.test.ts new file mode 100644 index 000000000..06d0768fd --- /dev/null +++ b/tests/owner-catalogue-cache.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadCacheModule() { + return import("../src/lib/owner-catalogue-cache"); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +afterEach(async () => { + vi.useRealTimers(); + const { clearOwnerCatalogueCache } = await loadCacheModule(); + clearOwnerCatalogueCache(); +}); + +describe("owner catalogue cache", () => { + it("reuses a successful catalogue across warm query prefixes", async () => { + const { loadOwnerCatalogue } = await loadCacheModule(); + const load = vi.fn(async () => [{ slug: "clozapine" }]); + const args = { ownerId: "owner-a", kind: "medication" as const, limit: 500, load }; + + const first = await loadOwnerCatalogue(args); + const warmerPrefix = await loadOwnerCatalogue(args); + + expect(warmerPrefix).toBe(first); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("coalesces concurrent same-key cold loads", async () => { + const { loadOwnerCatalogue } = await loadCacheModule(); + const pending = deferred>(); + const load = vi.fn(async () => pending.promise); + const args = { ownerId: "owner-concurrent", kind: "service" as const, limit: 500, load }; + + const first = loadOwnerCatalogue(args); + const second = loadOwnerCatalogue(args); + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + + const catalogue = [{ slug: "crisis-service" }]; + pending.resolve(catalogue); + const [firstValue, secondValue] = await Promise.all([first, second]); + expect(firstValue).toBe(secondValue); + expect(firstValue).toEqual(catalogue); + }); + + it("does not cache a failed shared load and retries after every waiter rejects", async () => { + const { loadOwnerCatalogue } = await loadCacheModule(); + const pending = deferred>(); + const load = vi + .fn<(signal: AbortSignal) => Promise>>() + .mockImplementationOnce(async () => pending.promise) + .mockResolvedValueOnce([{ slug: "recovered-service" }]); + const args = { ownerId: "owner-shared-failure", kind: "service" as const, limit: 500, load }; + + const first = loadOwnerCatalogue(args); + const second = loadOwnerCatalogue(args); + await Promise.resolve(); + pending.reject(new Error("shared load failed")); + const outcomes = await Promise.allSettled([first, second]); + expect(outcomes.map((outcome) => outcome.status)).toEqual(["rejected", "rejected"]); + + await expect(loadOwnerCatalogue(args)).resolves.toEqual([{ slug: "recovered-service" }]); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("lets an active waiter complete when a coalesced caller aborts", async () => { + const { loadOwnerCatalogue } = await loadCacheModule(); + const pending = deferred>(); + let sharedSignal: AbortSignal | undefined; + const load = vi.fn(async (signal: AbortSignal) => { + sharedSignal = signal; + return pending.promise; + }); + const firstController = new AbortController(); + const secondController = new AbortController(); + const args = { ownerId: "owner-partial-abort", kind: "medication" as const, limit: 500, load }; + + const abortedCaller = loadOwnerCatalogue({ ...args, signal: firstController.signal }); + const activeCaller = loadOwnerCatalogue({ ...args, signal: secondController.signal }); + await Promise.resolve(); + firstController.abort(new DOMException("superseded", "AbortError")); + await expect(abortedCaller).rejects.toMatchObject({ name: "AbortError" }); + expect(sharedSignal?.aborted).toBe(false); + + const catalogue = [{ slug: "clozapine" }]; + pending.resolve(catalogue); + await expect(activeCaller).resolves.toEqual(catalogue); + await expect(loadOwnerCatalogue(args)).resolves.toEqual(catalogue); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("does not start or populate a flight for an already-aborted caller", async () => { + const { loadOwnerCatalogue } = await loadCacheModule(); + const controller = new AbortController(); + controller.abort(new DOMException("already superseded", "AbortError")); + const load = vi.fn(async () => [{ slug: "must-not-cache" }]); + const args = { ownerId: "owner-pre-aborted", kind: "form" as const, limit: 500, load }; + + await expect(loadOwnerCatalogue({ ...args, signal: controller.signal })).rejects.toMatchObject({ + name: "AbortError", + }); + expect(load).not.toHaveBeenCalled(); + + await expect(loadOwnerCatalogue(args)).resolves.toEqual([{ slug: "must-not-cache" }]); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("expires values after five seconds and retains at most 128 keys", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-17T00:00:00Z")); + const { loadOwnerCatalogue } = await loadCacheModule(); + const expiringLoad = vi.fn(async () => [{ slug: "clozapine" }]); + + await loadOwnerCatalogue({ ownerId: "owner-expiring", kind: "medication", limit: 500, load: expiringLoad }); + vi.advanceTimersByTime(4_999); + await loadOwnerCatalogue({ ownerId: "owner-expiring", kind: "medication", limit: 500, load: expiringLoad }); + vi.advanceTimersByTime(2); + await loadOwnerCatalogue({ ownerId: "owner-expiring", kind: "medication", limit: 500, load: expiringLoad }); + expect(expiringLoad).toHaveBeenCalledTimes(2); + + const loads = Array.from({ length: 129 }, () => vi.fn(async () => [{ ok: true }])); + for (let index = 0; index < loads.length; index += 1) { + await loadOwnerCatalogue({ + ownerId: `owner-${index}`, + kind: "service", + limit: 500, + load: loads[index], + }); + } + await loadOwnerCatalogue({ ownerId: "owner-0", kind: "service", limit: 500, load: loads[0] }); + expect(loads[0]).toHaveBeenCalledTimes(2); + }); + + it("never caches failed or aborted loads", async () => { + const { loadOwnerCatalogue } = await loadCacheModule(); + const failedLoad = vi + .fn<() => Promise>>() + .mockRejectedValueOnce(new Error("load failed")) + .mockResolvedValueOnce([{ slug: "clozapine" }]); + const failedArgs = { ownerId: "owner-failed", kind: "medication" as const, limit: 500, load: failedLoad }; + + await expect(loadOwnerCatalogue(failedArgs)).rejects.toThrow("load failed"); + await expect(loadOwnerCatalogue(failedArgs)).resolves.toEqual([{ slug: "clozapine" }]); + expect(failedLoad).toHaveBeenCalledTimes(2); + + const controller = new AbortController(); + const abortedLoad = vi.fn(async () => { + controller.abort(new DOMException("superseded", "AbortError")); + return [{ slug: "acamprosate" }]; + }); + const abortedArgs = { + ownerId: "owner-aborted", + kind: "medication" as const, + limit: 500, + signal: controller.signal, + load: abortedLoad, + }; + + await expect(loadOwnerCatalogue(abortedArgs)).rejects.toMatchObject({ name: "AbortError" }); + const retry = new AbortController(); + await loadOwnerCatalogue({ ...abortedArgs, signal: retry.signal }); + expect(abortedLoad).toHaveBeenCalledTimes(2); + }); + + it("invalidates only the mutated owner and catalogue kind across limits", async () => { + const { invalidateOwnerCatalogueCache, loadOwnerCatalogue } = await loadCacheModule(); + const ownerMedication = vi.fn(async () => [{ slug: "clozapine" }]); + const ownerService = vi.fn(async () => [{ slug: "crisis-service" }]); + const otherMedication = vi.fn(async () => [{ slug: "lithium" }]); + + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load: ownerMedication }); + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 100, load: ownerMedication }); + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "service", limit: 500, load: ownerService }); + await loadOwnerCatalogue({ ownerId: "owner-b", kind: "medication", limit: 500, load: otherMedication }); + + invalidateOwnerCatalogueCache({ ownerId: "owner-a", kind: "medication" }); + + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load: ownerMedication }); + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 100, load: ownerMedication }); + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "service", limit: 500, load: ownerService }); + await loadOwnerCatalogue({ ownerId: "owner-b", kind: "medication", limit: 500, load: otherMedication }); + + expect(ownerMedication).toHaveBeenCalledTimes(4); + expect(ownerService).toHaveBeenCalledTimes(1); + expect(otherMedication).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index ba2aaf4a0..c6da6cf62 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -143,6 +143,11 @@ class QueryBuilder implements PromiseLike { return this; } + abortSignal(signal: AbortSignal) { + void signal; + return this; + } + single() { this.call.single = true; return this.resolve(); @@ -241,6 +246,12 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { error: null, }; } + if (name === "consume_summary_rate_limits_atomic") { + return { + data: [rateLimitRow({ bucket: null, limit_value: 12 })], + error: null, + }; + } if (name === "consume_api_subject_rate_limit") { const bucket = String(args?.p_bucket ?? ""); const limit = Number(args?.p_limit ?? 100); @@ -2928,7 +2939,7 @@ describe("private document API access", () => { expect(update).not.toHaveProperty("storage_path"); expect(update).not.toHaveProperty("content_hash"); expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); - expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId); + expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false }); }); it("rejects invalid document rename titles", async () => { @@ -3004,7 +3015,7 @@ describe("private document API access", () => { source: "manual", confidence: 1, }); - expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId); + expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false }); }); it("creates manual site labels for owned documents", async () => { @@ -3044,7 +3055,7 @@ describe("private document API access", () => { source: "manual", confidence: 1, }); - expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId); + expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false }); }); it("rejects noisy manual document labels before insert", async () => { @@ -3098,7 +3109,7 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(update?.filters).toContainEqual({ column: "source", value: "manual" }); expect(update?.updatePayload).toMatchObject({ label: "lithium toxicity", label_type: "risk", source: "manual" }); - expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId); + expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false }); }); it.each([ @@ -3158,7 +3169,7 @@ describe("private document API access", () => { expect(update?.updatePayload).toMatchObject({ metadata: expect.objectContaining({ review_status: reviewStatus, hidden, reviewed_by: "label-review-admin" }), }); - expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId); + expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false }); }); it("does not review labels that are not owned by the authenticated user", async () => { @@ -3275,7 +3286,7 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(await payload(response)).toMatchObject({ deleted: true, labelId: manualLabelId, labels: [] }); expect(deleteCall?.filters).toContainEqual({ column: "source", value: "manual" }); - expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId); + expect(invalidateRagCachesForDocumentMutation).toHaveBeenCalledWith(userId, { affectsPublicCorpus: false }); }); it("permanently deletes an owned document, indexing traces, and storage objects", async () => { @@ -4265,9 +4276,15 @@ describe("private document API access", () => { expect(summarizeDocument).toHaveBeenCalledWith(documentId, userId, { signal: expect.any(AbortSignal), }); + expect(client.rpc).toHaveBeenCalledTimes(1); expect(client.rpc).toHaveBeenCalledWith( - "consume_api_rate_limit", - expect.objectContaining({ p_owner_id: userId, p_bucket: "document_summarize" }), + "consume_summary_rate_limits_atomic", + expect.objectContaining({ + p_owner_id: userId, + p_subject_key: null, + p_answer_limit: 30, + p_summary_limit: 12, + }), ); expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); @@ -4281,12 +4298,20 @@ describe("private document API access", () => { sources: [], })); const client = createSupabaseMock(); - client.rpc.mockImplementation(async (name: string, args?: Record) => - name === "consume_api_rate_limit" && args?.p_bucket === "document_summarize" - ? { data: [rateLimitRow({ limited: true, limit_value: 12, remaining: 0 })], error: null } - : name === "consume_api_rate_limit" - ? { data: [rateLimitRow()], error: null } - : ok([]), + client.rpc.mockImplementation(async (name: string) => + name === "consume_summary_rate_limits_atomic" + ? { + data: [ + rateLimitRow({ + bucket: "document_summarize", + limited: true, + limit_value: 12, + remaining: 0, + }), + ], + error: null, + } + : ok([]), ); mockRuntime(client, { summarizeDocument }); const { POST } = await import("../src/app/api/answer/stream/route"); @@ -4308,16 +4333,45 @@ describe("private document API access", () => { error: "Too many document summary requests. Retry shortly.", details: { retryAfterSeconds: 60 }, }); - expect(client.rpc).toHaveBeenNthCalledWith( - 1, - "consume_api_rate_limit", - expect.objectContaining({ p_owner_id: userId, p_bucket: "answer" }), + expect(client.rpc).toHaveBeenCalledTimes(1); + expect(client.rpc).toHaveBeenCalledWith( + "consume_summary_rate_limits_atomic", + expect.objectContaining({ p_owner_id: userId, p_subject_key: null }), ); - expect(client.rpc).toHaveBeenNthCalledWith( - 2, - "consume_api_rate_limit", - expect.objectContaining({ p_owner_id: userId, p_bucket: "document_summarize" }), + expect(summarizeDocument).not.toHaveBeenCalled(); + }); + + it("preserves the general answer rejection for atomic streamed-summary limits", async () => { + const summarizeDocument = vi.fn(); + const client = createSupabaseMock(); + client.rpc.mockImplementation(async (name: string) => + name === "consume_summary_rate_limits_atomic" + ? { + data: [rateLimitRow({ bucket: "answer", limited: true, limit_value: 30, remaining: 0 })], + error: null, + } + : ok([]), ); + mockRuntime(client, { summarizeDocument }); + const { POST } = await import("../src/app/api/answer/stream/route"); + + const response = await POST( + authenticatedRequest("/api/answer/stream", { + method: "POST", + body: JSON.stringify({ + query: "Summarize this document for practical clinical use.", + documentId, + summaryMode: true, + }), + }), + ); + + expect(response.status).toBe(429); + expect(await payload(response)).toMatchObject({ + error: "Too many answer requests. Retry shortly.", + details: { retryAfterSeconds: 60 }, + }); + expect(client.rpc).toHaveBeenCalledTimes(1); expect(summarizeDocument).not.toHaveBeenCalled(); }); diff --git a/tests/private-error-cache.test.ts b/tests/private-error-cache.test.ts new file mode 100644 index 000000000..bf078ca74 --- /dev/null +++ b/tests/private-error-cache.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from "vitest"; + +import { rateLimitJsonResponse } from "../src/lib/api-rate-limit"; +import { jsonError } from "../src/lib/http"; +import { unauthorizedResponse } from "../src/lib/supabase/auth"; + +describe("private API error cache policy", () => { + it("marks validation, authentication, and rate-limit responses private", () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const validation = jsonError("Invalid request.", 400); + const unauthorized = unauthorizedResponse(); + const limited = rateLimitJsonResponse("Too many requests.", { + limited: true, + limit: 1, + remaining: 0, + retryAfterSeconds: 60, + resetAt: new Date(60_000).toISOString(), + }); + + expect(validation.headers.get("Cache-Control")).toBe("private, no-store"); + expect(unauthorized.headers.get("Cache-Control")).toBe("private, no-store"); + expect(limited.headers.get("Cache-Control")).toBe("private, no-store"); + expect(limited.headers.get("Retry-After")).toBe("60"); + }); +}); diff --git a/tests/private-rag-access.test.ts b/tests/private-rag-access.test.ts index b548dfada..8f986a2f3 100644 --- a/tests/private-rag-access.test.ts +++ b/tests/private-rag-access.test.ts @@ -195,7 +195,8 @@ function mockRuntime(options: { demoMode?: boolean } = {}) { buildVisualEvidence: vi.fn(() => []), diversifySearchResults: vi.fn((results: unknown[]) => results), })); - vi.doMock("@/lib/evidence-relevance", () => ({ + vi.doMock("@/lib/evidence-relevance", async (importOriginal) => ({ + ...(await importOriginal()), annotateDocumentMatches: vi.fn((_query: string, documents: unknown[]) => documents), annotateSearchResults: vi.fn((_query: string, results: unknown[]) => results), buildEvidenceRelevance: vi.fn(() => ({ diff --git a/tests/proxy-session-refresh.test.ts b/tests/proxy-session-refresh.test.ts index c380b94e1..a68cb1177 100644 --- a/tests/proxy-session-refresh.test.ts +++ b/tests/proxy-session-refresh.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; -// The proxy's second job (session-cookie refresh via supabase.auth.getUser) +// The proxy's second job (session-cookie refresh via supabase.auth.getClaims) // must run on page navigations and cookie-authenticated API routes. Route // handlers can validate an SSR cookie, but their read-only adapter cannot return // rotated Set-Cookie headers to the browser. -const getUser = vi.fn(async () => ({ data: { user: null }, error: null })); +const getClaims = vi.fn(async () => ({ data: { claims: null }, error: null })); vi.mock("@supabase/ssr", async (importOriginal) => { const actual = await importOriginal(); @@ -14,7 +14,7 @@ vi.mock("@supabase/ssr", async (importOriginal) => { ...actual, createServerClient: vi.fn((_url, _key, options: { cookies: { setAll: (cookies: never[]) => void } }) => ({ auth: { - getUser: async () => { + getClaims: async () => { options.cookies.setAll([ { name: "sb-unit-test-auth-token", @@ -22,7 +22,7 @@ vi.mock("@supabase/ssr", async (importOriginal) => { options: { path: "/", httpOnly: true }, }, ] as never[]); - return getUser(); + return getClaims(); }, }, })), @@ -48,7 +48,7 @@ function requestWithSessionCookie(path: string): NextRequest { } beforeEach(() => { - getUser.mockClear(); + getClaims.mockClear(); }); describe("proxy session refresh scoping", () => { @@ -56,7 +56,7 @@ describe("proxy session refresh scoping", () => { const { proxy } = await import("../src/proxy"); const response = await proxy(requestWithSessionCookie("/api/answer")); - expect(getUser).toHaveBeenCalledTimes(1); + expect(getClaims).toHaveBeenCalledTimes(1); expect(response.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session"); expect(response.headers.get("content-security-policy")).toBeTruthy(); }); @@ -65,16 +65,16 @@ describe("proxy session refresh scoping", () => { const { proxy } = await import("../src/proxy"); const response = await proxy(requestWithSessionCookie("/documents/some-id")); - expect(getUser).toHaveBeenCalledTimes(1); + expect(getClaims).toHaveBeenCalledTimes(1); expect(response.cookies.get("sb-unit-test-auth-token")?.value).toBe("rotated-session"); expect(response.headers.get("content-security-policy")).toBeTruthy(); }); - it("never calls getUser without an sb- cookie", async () => { + it("never calls getClaims without an sb- cookie", async () => { const { proxy } = await import("../src/proxy"); await proxy(new NextRequest(new URL("http://localhost/documents/some-id"))); - expect(getUser).not.toHaveBeenCalled(); + expect(getClaims).not.toHaveBeenCalled(); }); it.each(["/sw.js", "/offline.html", "/manifest.webmanifest", "/apple-icon", "/icon.svg", "/icons/icon-192"])( @@ -83,7 +83,7 @@ describe("proxy session refresh scoping", () => { const { proxy } = await import("../src/proxy"); const response = await proxy(requestWithSessionCookie(path)); - expect(getUser).not.toHaveBeenCalled(); + expect(getClaims).not.toHaveBeenCalled(); expect(response.headers.get("content-security-policy")).toBeNull(); }, ); diff --git a/tests/rag-abort-signal.test.ts b/tests/rag-abort-signal.test.ts index f1fddbbfa..ad5391f31 100644 --- a/tests/rag-abort-signal.test.ts +++ b/tests/rag-abort-signal.test.ts @@ -33,6 +33,23 @@ describe("RAG abort signal propagation", () => { expect(createAdminClient).not.toHaveBeenCalled(); }, 60_000); + it("attaches the caller signal to versioned retrieval RPC builders", async () => { + const controller = new AbortController(); + const abortSignal = vi.fn(async () => ({ data: [], error: null })); + const supabase = { rpc: vi.fn(() => ({ abortSignal })) }; + const { callVersionedRetrievalRpc } = await import("../src/lib/rag-candidate-sources"); + + await callVersionedRetrievalRpc( + supabase as never, + "match_document_chunks_text_v2", + "match_document_chunks_text", + { query_text: "clozapine", match_count: 8 }, + controller.signal, + ); + + expect(abortSignal).toHaveBeenCalledWith(controller.signal); + }); + it("refuses adversarial manipulation before Supabase work starts", async () => { const createAdminClient = vi.fn(); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); diff --git a/tests/rag-cache-invalidation.test.ts b/tests/rag-cache-invalidation.test.ts index 3bcd4a9b7..afdbbbe28 100644 --- a/tests/rag-cache-invalidation.test.ts +++ b/tests/rag-cache-invalidation.test.ts @@ -61,5 +61,15 @@ describe("RAG cache invalidation", () => { expect(calls.flat()).not.toContainEqual({ method: "eq", column: "owner_id", value: "anonymous" }); expect(calls[1]).toContainEqual({ method: "is", column: "owner_id", value: null }); expect(calls[1]).toContainEqual({ method: "in", column: "cache_kind", value: ["search", "answer"] }); + + calls.length = 0; + invalidateRagCachesForDocumentMutation(ownerId, { affectsPublicCorpus: false }); + await vi.waitFor(() => expect(calls.length).toBe(1), { timeout: 10000 }); + expect(calls[0]).toContainEqual({ method: "eq", column: "owner_id", value: ownerId }); + + calls.length = 0; + invalidateRagCachesForDocumentMutation(ownerId, { affectsPublicCorpus: true }); + await vi.waitFor(() => expect(calls.length).toBe(2), { timeout: 10000 }); + expect(calls[1]).toContainEqual({ method: "is", column: "owner_id", value: null }); }); }); diff --git a/tests/rag-variant-early-exit.test.ts b/tests/rag-variant-early-exit.test.ts index cf8e8db35..1a71d5848 100644 --- a/tests/rag-variant-early-exit.test.ts +++ b/tests/rag-variant-early-exit.test.ts @@ -86,10 +86,11 @@ async function runLexicalSearch(chunkResults: SearchResult[]) { if (retrievalRpcBaseName(name) === "match_document_chunks_text") return { data: chunkResults, error: null }; return { data: [], error: null }; }); + const from = vi.fn(() => new EmptyQuery()); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => ({ rpc, - from: vi.fn(() => new EmptyQuery()), + from, }), })); @@ -103,7 +104,7 @@ async function runLexicalSearch(chunkResults: SearchResult[]) { const chunkTextCalls = rpc.mock.calls.filter( ([name]) => retrievalRpcBaseName(name as string) === "match_document_chunks_text", ); - return { chunkTextCalls, telemetry: result.telemetry }; + return { chunkTextCalls, from, telemetry: result.telemetry }; } afterEach(() => { @@ -117,13 +118,14 @@ describe("lexical variant early-exit (PT-02)", () => { it("a deep, precisely-anchored first pool issues exactly one chunk-text RPC", async () => { // Deep (48-row) pool with a precise top hit: sibling variants are pure duplication. const strongPool = Array.from({ length: 48 }, (_, index) => chunk(index, index === 0 ? 0.9 : 0.2)); - const { chunkTextCalls, telemetry } = await runLexicalSearch(strongPool); + const { chunkTextCalls, from, telemetry } = await runLexicalSearch(strongPool); // Guard: the fixture query must actually produce sibling variants for the // skip to be meaningful. expect(telemetry.retrieval_query_variant_count ?? 1).toBeGreaterThan(1); expect(chunkTextCalls).toHaveLength(1); expect(telemetry.text_variant_early_exit).toBe(true); expect(telemetry.text_variant_rpc_calls?.match_document_chunks_text).toBe(1); + expect(from).not.toHaveBeenCalledWith("document_images"); }); it("a weak first pool keeps the full sibling fan-out", async () => { diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts index 19d08f3a5..a30b0ca71 100644 --- a/tests/registry-records-route.test.ts +++ b/tests/registry-records-route.test.ts @@ -2,6 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; const token = "valid-token"; +const publicFixtureCacheControl = "public, max-age=300, s-maxage=3600, stale-while-revalidate=86400"; + +function expectPublicFixtureCache(response: Response) { + expect(response.headers.get("cache-control")).toBe(publicFixtureCacheControl); + expect(response.headers.get("vary")).toBe("Cookie, Authorization"); +} + +function expectPrivateCache(response: Response) { + expect(response.headers.get("cache-control")).toBe("private, no-store"); +} const recordId = "11111111-1111-4111-8111-111111111111"; type QueryError = { message: string }; @@ -192,6 +202,7 @@ describe("registry records API", () => { const payload = (await response.json()) as { records: Array<{ slug: string }>; demoMode?: boolean }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.demoMode).toBe(true); expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true); expect(client.from).not.toHaveBeenCalled(); @@ -210,6 +221,7 @@ describe("registry records API", () => { publicAccess?: boolean; }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.publicAccess).toBe(true); expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true); expect(payload.matches?.[0]?.record.slug).toBe("13yarn"); @@ -257,6 +269,7 @@ describe("registry records API", () => { }; expect(response.status).toBe(200); + expectPrivateCache(response); expect(payload.records[0]?.slug).toBe("13yarn"); expect(payload.matches?.[0]?.record.slug).toBe("13yarn"); expect(payload.governance["13yarn"]?.validationStatus).toBe("locally_reviewed"); @@ -338,6 +351,7 @@ describe("registry records API", () => { }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.publicAccess).toBe(true); expect(payload.record.slug).toBe("13yarn"); expect(payload.linkedDocuments).toEqual([]); @@ -385,6 +399,7 @@ describe("registry records API", () => { const payload = (await response.json()) as { record: { slug: string }; demoMode?: boolean }; expect(response.status).toBe(200); + expectPublicFixtureCache(response); expect(payload.demoMode).toBe(true); expect(payload.record.slug).toBe("transport-crisis-form"); expect(client.from).not.toHaveBeenCalled(); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 2e1b4634a..736b9ffc3 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -168,6 +168,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/20260717130000_registry_projection_cleanup.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const publicTitleCorrectorMigration = readFileSync( + new URL("../supabase/migrations/20260717131000_public_title_corrector.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); +const atomicSummaryRateLimitsMigration = readFileSync( + new URL("../supabase/migrations/20260717132000_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", @@ -1186,6 +1198,99 @@ describe("Supabase Preview replay guards", () => { expect(ingestionRpcPrivilegesDuplicateMigration).toContain("NEUTRALIZED 2026-07-13"); 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("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, + ); + 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( + "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, service_role;", + ); + 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); + expect(corrector).toContain("best_sim >= min_sim"); + expect(corrector).toContain("length(best) >= length(tok)"); + } + }); + + 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).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/, + ); + } + }); }); describe("Clinical query-term corrector — tenant-safe vocabulary (F10)", () => { diff --git a/tests/universal-search-stream.test.ts b/tests/universal-search-stream.test.ts new file mode 100644 index 000000000..338be2f88 --- /dev/null +++ b/tests/universal-search-stream.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { UniversalSearchGroup, UniversalSearchResponse } from "../src/lib/universal-search"; + +const encoder = new TextEncoder(); + +function responseFromChunks(chunks: string[]) { + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), + { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } }, + ); +} + +describe("consumeUniversalSearchNdjson", () => { + it("emits progressive groups and returns the canonical final response across split chunks", async () => { + const { consumeUniversalSearchNdjson } = await import("../src/lib/universal-search-stream"); + const group: UniversalSearchGroup = { + kind: "medications", + total: 1, + latencyMs: 3, + items: [ + { + id: "clozapine", + kind: "medications", + title: "Clozapine", + href: "/medications/clozapine", + score: 10, + }, + ], + }; + const final: UniversalSearchResponse = { + query: "clozapine", + groups: [group], + tookMs: 4, + domainOrder: ["medications"], + }; + const payload = `${JSON.stringify({ type: "group", query: "clozapine", group })}\n${JSON.stringify({ + type: "complete", + response: final, + })}\n`; + const splitAt = payload.indexOf("Clozapine") + 4; + const onGroup = vi.fn(); + + const result = await consumeUniversalSearchNdjson( + responseFromChunks([payload.slice(0, splitAt), payload.slice(splitAt)]), + { onGroup }, + ); + + expect(onGroup).toHaveBeenCalledOnce(); + expect(onGroup).toHaveBeenCalledWith(group, "clozapine"); + expect(result).toEqual(final); + }); + + it("cancels the reader and rejects when the caller aborts", async () => { + const { consumeUniversalSearchNdjson } = await import("../src/lib/universal-search-stream"); + const cancelled = vi.fn(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `${JSON.stringify({ + type: "group", + query: "clozapine", + group: { kind: "medications", total: 0, latencyMs: 1, items: [] }, + })}\n`, + ), + ); + }, + cancel(reason) { + cancelled(reason); + }, + }), + { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } }, + ); + const controller = new AbortController(); + const pending = consumeUniversalSearchNdjson(response, { signal: controller.signal }); + await Promise.resolve(); + + controller.abort(new DOMException("superseded", "AbortError")); + + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(cancelled).toHaveBeenCalledOnce(); + }); + + it("rejects a truncated stream without a complete event", async () => { + const { consumeUniversalSearchNdjson } = await import("../src/lib/universal-search-stream"); + const response = responseFromChunks([ + `${JSON.stringify({ + type: "group", + query: "clozapine", + group: { kind: "medications", total: 0, latencyMs: 1, items: [] }, + })}\n`, + ]); + + await expect(consumeUniversalSearchNdjson(response)).rejects.toThrow("complete event"); + }); +}); diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index d5c84b80f..7739965a0 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -171,50 +171,52 @@ describe("runUniversalSearch (demo/fixtures path)", () => { it("keeps registry hrefs when document search uses related-document mapping", async () => { isolateNextModuleImport(); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [ + { + id: "registry-chunk", + document_id: "registry-doc", + title: "Crisis service", + file_name: "service-crisis-service.registry.json", + page_number: 1, + hybrid_score: 0.92, + similarity: 0.9, + images: [], + source_metadata: { + source_kind: "registry_record", + registry_record_kind: "service", + registry_record_slug: "crisis-service", + }, + }, + ], + })); vi.doMock("@/lib/rag", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - searchChunksWithTelemetry: vi.fn(async () => ({ - results: [ - { - id: "registry-chunk", - document_id: "registry-doc", - title: "Crisis service", - file_name: "service-crisis-service.registry.json", - page_number: 1, - hybrid_score: 0.92, - similarity: 0.9, - images: [], - source_metadata: { - source_kind: "registry_record", - registry_record_kind: "service", - registry_record_slug: "crisis-service", - }, - }, - ], - })), + searchChunksWithTelemetry, }; }); + const fetchRelatedDocuments = vi.fn(async () => [ + { + document_id: "registry-doc", + title: "Crisis service", + file_name: "service-crisis-service.registry.json", + labels: [], + summary: null, + best_pages: [1], + best_chunk_ids: ["registry-chunk"], + image_count: 0, + table_count: 0, + match_reason: "Matched 1 indexed passage", + score: 0.92, + }, + ]); vi.doMock("@/lib/document-enrichment", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - fetchRelatedDocuments: vi.fn(async () => [ - { - document_id: "registry-doc", - title: "Crisis service", - file_name: "service-crisis-service.registry.json", - labels: [], - summary: null, - best_pages: [1], - best_chunk_ids: ["registry-chunk"], - image_count: 0, - table_count: 0, - match_reason: "Matched 1 indexed passage", - score: 0.92, - }, - ]), + fetchRelatedDocuments, }; }); @@ -228,6 +230,12 @@ describe("runUniversalSearch (demo/fixtures path)", () => { }); expect(response.groups[0]?.items[0]?.href).toBe("/services/crisis-service"); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(fetchRelatedDocuments).toHaveBeenCalledWith( + expect.objectContaining({ includeVisualCounts: false, signal: expect.any(AbortSignal) }), + ); }); }); @@ -299,6 +307,38 @@ describe("GET /api/search/universal (demo mode)", () => { const invalid = await GET(new Request("http://localhost/api/search/universal?q=transport&mode=bogus")); expect(invalid.status).toBe(400); }); + + it("streams ready groups as NDJSON and finishes with the canonical response", async () => { + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "true"); + const { GET } = await import("../src/app/api/search/universal/route"); + const response = await GET( + new Request( + "http://localhost/api/search/universal?q=acamprosate&limit=3&domains=medications,tools&stream=ndjson", + ), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("application/x-ndjson"); + const events = (await response.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + const groupEvents = events.filter((event) => event.type === "group") as Array<{ + query: string; + group: { kind: string }; + }>; + const complete = events.at(-1) as { + type: string; + response: { query: string; groups: Array<{ kind: string }>; demoMode?: boolean }; + }; + + expect(groupEvents).toHaveLength(2); + expect(groupEvents.every((event) => event.query === "acamprosate")).toBe(true); + expect(complete.type).toBe("complete"); + expect(complete.response.demoMode).toBe(true); + expect(complete.response.groups.map((group) => group.kind)).toEqual(["medications", "tools"]); + expect(new Set(groupEvents.map((event) => event.group.kind))).toEqual(new Set(["medications", "tools"])); + }); }); describe("runUniversalSearch (query intelligence & ranking)", () => { @@ -455,6 +495,99 @@ describe("runUniversalSearch (query intelligence & ranking)", () => { }); }); +describe("runUniversalSearch (owner catalogue cache)", () => { + it("reads each owner catalogue once and reuses it across warmer prefixes", async () => { + isolateNextModuleImport(); + const fetchOwnerMedicationRowsWithSeed = vi.fn< + (supabase: unknown, ownerId: string, limit: number, options: { select?: string }) => Promise + >(async () => [ + { + slug: "clozapine", + name: "Clozapine", + class: "Antipsychotic", + subclass: "Second generation", + category: "Psychosis", + tag: "High monitoring", + schedule: "S4", + stats: [], + sections: [], + quick: [], + }, + ]); + const fetchOwnerRegistryRows = vi.fn< + ( + supabase: unknown, + ownerId: string, + kind: string, + limit: number, + options: { select?: string }, + ) => Promise + >(async () => [ + { + slug: "clozapine-clinic", + title: "Clozapine clinic", + subtitle: null, + status_chips: [], + primary_contact: null, + contacts: [], + route: null, + eligibility: null, + cost: null, + referral: null, + location: null, + summary_cards: [], + referral_info: [], + best_use: null, + criteria: [], + verification: {}, + tags: [], + catchments: [], + catalogue_label: null, + navigator_query: null, + source: {}, + catalog_payload: {}, + }, + ]); + vi.doMock("@/lib/medication-seed", async (importOriginal) => ({ + ...(await importOriginal()), + fetchOwnerMedicationRowsWithSeed, + })); + vi.doMock("@/lib/registry-seed", async (importOriginal) => ({ + ...(await importOriginal()), + fetchOwnerRegistryRows, + })); + + const { runUniversalSearch } = await loadUniversalSearch(); + const args = { + limitPerDomain: 3, + domains: ["medications", "services"] as Array<"medications" | "services">, + demo: false, + ownerId: "owner-a", + supabase: {} as never, + }; + await runUniversalSearch({ ...args, query: "clo" }); + await runUniversalSearch({ ...args, query: "cloz" }); + + expect(fetchOwnerMedicationRowsWithSeed).toHaveBeenCalledTimes(1); + expect(fetchOwnerRegistryRows).toHaveBeenCalledTimes(1); + expect(fetchOwnerMedicationRowsWithSeed).toHaveBeenCalledWith( + args.supabase, + "owner-a", + 500, + expect.objectContaining({ select: expect.any(String), signal: expect.any(AbortSignal) }), + ); + expect(fetchOwnerRegistryRows).toHaveBeenCalledWith( + args.supabase, + "owner-a", + "service", + 500, + expect.objectContaining({ select: expect.any(String), signal: expect.any(AbortSignal) }), + ); + expect(fetchOwnerMedicationRowsWithSeed.mock.calls[0]?.[3]?.select).not.toBe("*"); + expect(fetchOwnerRegistryRows.mock.calls[0]?.[4]?.select).not.toBe("*"); + }); +}); + // Regression guard: outside an explicit demo/local deploy, no caller — including an anonymous, // no-cookie live visitor — may be served the synthetic demo corpus. The documents domain must // reach the real retrieval pipeline (demo:false + supabase), scoped to the public corpus for diff --git a/tests/use-universal-search-stream.dom.test.tsx b/tests/use-universal-search-stream.dom.test.tsx new file mode 100644 index 000000000..83f990563 --- /dev/null +++ b/tests/use-universal-search-stream.dom.test.tsx @@ -0,0 +1,191 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useUniversalSearch } from "@/components/clinical-dashboard/use-universal-search"; +import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search"; +import type { UniversalSearchStreamEvent } from "@/lib/universal-search-stream"; + +const authSession = vi.hoisted(() => ({ + authorizationHeader: { Authorization: "Bearer universal-search-test" }, +})); + +vi.mock("@/lib/supabase/client", () => ({ + useAuthSession: () => authSession, +})); + +const encoder = new TextEncoder(); + +function controlledNdjsonResponse() { + let controller: ReadableStreamDefaultController; + const cancelled = vi.fn(); + const response = new Response( + new ReadableStream({ + start(streamController) { + controller = streamController; + }, + cancel(reason) { + cancelled(reason); + }, + }), + { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } }, + ); + + return { + response, + cancelled, + write(event: UniversalSearchStreamEvent) { + controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`)); + }, + close() { + controller.close(); + }, + }; +} + +function searchGroup(kind: "medications" | "tools", title: string): UniversalSearchGroup { + return { + kind, + total: 1, + latencyMs: 4, + items: [ + { + id: `${kind}-${title.toLowerCase().replaceAll(" ", "-")}`, + kind, + title, + href: `/${kind}/${title.toLowerCase().replaceAll(" ", "-")}`, + score: 10, + }, + ], + }; +} + +async function startDebouncedRequest() { + await act(async () => { + await vi.advanceTimersByTimeAsync(250); + }); +} + +async function flushStream() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +let fetchMock: ReturnType>; + +beforeEach(() => { + vi.useFakeTimers(); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useUniversalSearch NDJSON integration", () => { + it("renders each ready group before committing the canonical final response", async () => { + const query = "progressive parity query"; + const stream = controlledNdjsonResponse(); + const medicationGroup = searchGroup("medications", "Clozapine"); + const toolsGroup = searchGroup("tools", "Dose calculator"); + const final: UniversalSearchResponse = { + query, + groups: [toolsGroup, medicationGroup], + tookMs: 12, + domainOrder: ["tools", "medications"], + contextMode: "answer", + preferredDomains: ["tools"], + }; + fetchMock.mockResolvedValue(stream.response); + + const { result } = renderHook(() => + useUniversalSearch({ query, enabled: true, contextMode: "answer", excludeDomains: ["documents"] }), + ); + await startDebouncedRequest(); + + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), "http://localhost"); + expect(requestUrl.searchParams.get("stream")).toBe("ndjson"); + + stream.write({ type: "group", query, group: medicationGroup }); + await flushStream(); + expect(result.current).toMatchObject({ groups: [medicationGroup], loading: true, query }); + + stream.write({ type: "group", query, group: toolsGroup }); + await flushStream(); + expect(result.current.groups).toEqual([medicationGroup, toolsGroup]); + expect(result.current.loading).toBe(true); + + stream.write({ type: "complete", response: final }); + stream.close(); + await flushStream(); + expect(result.current).toMatchObject({ + groups: final.groups, + loading: false, + query, + domainOrder: final.domainOrder, + contextMode: final.contextMode, + preferredDomains: final.preferredDomains, + }); + }); + + it("aborts superseded streams, ignores their partial state, and caches only completed responses", async () => { + const firstQuery = "uncached partial query"; + const secondQuery = "completed cached query"; + const firstStream = controlledNdjsonResponse(); + const secondStream = controlledNdjsonResponse(); + const thirdStream = controlledNdjsonResponse(); + const firstGroup = searchGroup("medications", "First partial"); + const secondGroup = searchGroup("tools", "Second complete"); + const streams = [firstStream, secondStream, thirdStream]; + const requestSignals: AbortSignal[] = []; + fetchMock.mockImplementation((_input, init) => { + requestSignals.push(init?.signal as AbortSignal); + return Promise.resolve(streams[requestSignals.length - 1].response); + }); + + const { result, rerender } = renderHook( + ({ query }) => useUniversalSearch({ query, enabled: true, contextMode: "answer" }), + { initialProps: { query: firstQuery } }, + ); + await startDebouncedRequest(); + firstStream.write({ type: "group", query: firstQuery, group: firstGroup }); + await flushStream(); + expect(result.current).toMatchObject({ groups: [firstGroup], loading: true }); + + rerender({ query: secondQuery }); + await flushStream(); + expect(requestSignals[0].aborted).toBe(true); + expect(firstStream.cancelled).toHaveBeenCalledOnce(); + expect(result.current.groups).toEqual([]); + + await startDebouncedRequest(); + const completedResponse: UniversalSearchResponse = { + query: secondQuery, + groups: [secondGroup], + tookMs: 8, + contextMode: "answer", + }; + secondStream.write({ type: "complete", response: completedResponse }); + secondStream.close(); + await flushStream(); + expect(result.current).toMatchObject({ groups: [secondGroup], loading: false, query: secondQuery }); + + // The aborted first query had only a partial group, so revisiting it must fetch again. + rerender({ query: firstQuery }); + await startDebouncedRequest(); + expect(fetchMock).toHaveBeenCalledTimes(3); + + // The completed second query is cached; returning to it aborts the third request and needs no fourth fetch. + rerender({ query: secondQuery }); + await flushStream(); + await startDebouncedRequest(); + expect(requestSignals[2].aborted).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(result.current).toMatchObject({ groups: [secondGroup], loading: false, query: secondQuery }); + }); +}); From f277d13512e85dcaae4f9b94d028a8087e41375a Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:48:08 +0800 Subject: [PATCH 2/8] fix: close performance remediation blockers Preserve title-vocabulary privacy during backfill, keep trigger ACLs hardened, prevent medication seed flights from aborting themselves, and make shared-dependency worktrees use the supported Webpack fallback. Refresh focused UI and schema coverage. --- scripts/dev-free-port.mjs | 20 +++++ src/lib/medication-seed.ts | 2 +- src/lib/owner-catalogue-cache.ts | 7 +- supabase/drift-manifest.json | 12 ++- .../20260717131000_public_title_corrector.sql | 16 ++-- supabase/schema.sql | 18 ++-- tests/owner-catalogue-cache.test.ts | 15 ++++ tests/supabase-schema.test.ts | 14 +++ tests/test-runner-safety.test.ts | 7 ++ tests/ui-smoke.spec.ts | 87 +++++++++++++++++-- tests/ui-universal-search.spec.ts | 40 +++++---- 11 files changed, 188 insertions(+), 50 deletions(-) diff --git a/scripts/dev-free-port.mjs b/scripts/dev-free-port.mjs index 6c3bbcf66..b5ba23ba0 100644 --- a/scripts/dev-free-port.mjs +++ b/scripts/dev-free-port.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -60,6 +61,24 @@ function removePortArgs(args) { return cleaned; } +function dependencyBundlerArgs(command, args) { + if (command !== "dev" || args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg))) { + return []; + } + + try { + const dependenciesPath = fs.realpathSync(path.join(projectRoot, "node_modules")); + const relativeDependenciesPath = path.relative(projectRoot, dependenciesPath); + const dependenciesAreExternal = + relativeDependenciesPath === ".." || + relativeDependenciesPath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDependenciesPath); + return dependenciesAreExternal ? ["--webpack"] : []; + } catch { + return []; + } +} + function canListenOnHost(port, host) { return new Promise((resolve) => { const server = net.createServer(); @@ -140,6 +159,7 @@ const child = spawn( "--port", String(freePort), ...removePortArgs(forwardedArgs), + ...dependencyBundlerArgs(parsedCommand.command, forwardedArgs), ], { cwd: projectRoot, diff --git a/src/lib/medication-seed.ts b/src/lib/medication-seed.ts index f9ee28bbb..239264fe9 100644 --- a/src/lib/medication-seed.ts +++ b/src/lib/medication-seed.ts @@ -33,7 +33,7 @@ export async function ensureMedicationsSeeded( if (options.signal) query = query.abortSignal(options.signal); const { data, error } = await query; if (error) throw new Error(`Medication seed failed: ${error.message}`); - invalidateOwnerCatalogueCache({ ownerId, kind: "medication" }); + invalidateOwnerCatalogueCache({ ownerId, kind: "medication", preserveSignal: options.signal }); throwIfAborted(options.signal); const seededRows = (data ?? []) as MedicationRecordRow[]; const { bestEffortSyncMedicationRows } = await loadRegistryCorpus(); diff --git a/src/lib/owner-catalogue-cache.ts b/src/lib/owner-catalogue-cache.ts index 1aab3ed88..20cd5c97d 100644 --- a/src/lib/owner-catalogue-cache.ts +++ b/src/lib/owner-catalogue-cache.ts @@ -180,7 +180,11 @@ export async function loadOwnerCatalogue(args: { } /** Remove every cached limit for the catalogue that was mutated. */ -export function invalidateOwnerCatalogueCache(args: { ownerId: string; kind?: OwnerCatalogueKind }) { +export function invalidateOwnerCatalogueCache(args: { + ownerId: string; + kind?: OwnerCatalogueKind; + preserveSignal?: AbortSignal; +}) { for (const [key, entry] of ownerCatalogueCache) { if (entry.ownerId === args.ownerId && (!args.kind || entry.kind === args.kind)) { ownerCatalogueCache.delete(key); @@ -188,6 +192,7 @@ export function invalidateOwnerCatalogueCache(args: { ownerId: string; kind?: Ow } for (const [key, flight] of ownerCatalogueFlights) { if (flight.ownerId === args.ownerId && (!args.kind || flight.kind === args.kind)) { + if (flight.controller.signal === args.preserveSignal) continue; ownerCatalogueFlights.delete(key); flight.controller.abort(new DOMException("Owner catalogue invalidated.", "AbortError")); } diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 9d6787aba..9ebd1831b 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-17T11:56:55.778Z", + "generated_at": "2026-07-17T13:13:55.034Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "35b7898f4c61cc49c43d9720526b2ca0742750bc508a42ca163581318786ee67", - "replay_seconds": 55, + "schema_sha256": "cea1a026138298931993d5bd5771c1c96b94ff77e9edaa88998cea3f1a3fb1b4", + "replay_seconds": 100, "snapshot": { "views": [ { @@ -6341,8 +6341,7 @@ }, { "acl": [ - "postgres=X/postgres", - "service_role=X/postgres" + "postgres=X/postgres" ], "def_hash": "7104ee6e947fcca68fdebdf2fa878339", "signature": "public.cleanup_registry_corpus_document()" @@ -6909,8 +6908,7 @@ }, { "acl": [ - "postgres=X/postgres", - "service_role=X/postgres" + "postgres=X/postgres" ], "def_hash": "6b51ece12494d136b39977d8532c013c", "signature": "public.sync_document_title_words()" diff --git a/supabase/migrations/20260717131000_public_title_corrector.sql b/supabase/migrations/20260717131000_public_title_corrector.sql index 43aa53c6e..c6895b846 100644 --- a/supabase/migrations/20260717131000_public_title_corrector.sql +++ b/supabase/migrations/20260717131000_public_title_corrector.sql @@ -22,14 +22,6 @@ alter table public.document_title_words enable row level security; revoke all on table public.document_title_words from public, anon, authenticated; grant select, insert, update, delete on table public.document_title_words to service_role; -insert into public.document_title_words (word, document_id) -select distinct lower(title_word), d.id -from public.documents d -cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word -where d.owner_id is null and d.status = 'indexed' - and length(title_word) between 4 and 40 -on conflict do nothing; - create or replace function public.sync_document_title_words() returns trigger language plpgsql @@ -61,6 +53,14 @@ create trigger documents_sync_title_words after insert or update of title, status, owner_id or delete on public.documents for each row execute function public.sync_document_title_words(); +insert into public.document_title_words (word, document_id) +select distinct lower(title_word), d.id +from public.documents d +cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word +where d.owner_id is null and d.status = 'indexed' + and length(title_word) between 4 and 40 +on conflict do nothing; + create or replace function public.correct_clinical_query_terms( input_query text, min_sim real default 0.45 diff --git a/supabase/schema.sql b/supabase/schema.sql index 2299ea492..dc7158519 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -5033,14 +5033,6 @@ alter table public.document_title_words enable row level security; revoke all on table public.document_title_words from public, anon, authenticated; grant select, insert, update, delete on table public.document_title_words to service_role; -insert into public.document_title_words (word, document_id) -select distinct lower(title_word), d.id -from public.documents d -cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word -where d.owner_id is null and d.status = 'indexed' - and length(title_word) between 4 and 40 -on conflict do nothing; - create or replace function public.sync_document_title_words() returns trigger language plpgsql @@ -5069,6 +5061,14 @@ create trigger documents_sync_title_words after insert or update of title, status, owner_id or delete on public.documents for each row execute function public.sync_document_title_words(); +insert into public.document_title_words (word, document_id) +select distinct lower(title_word), d.id +from public.documents d +cross join lateral unnest(regexp_split_to_array(lower(d.title), '[^a-z]+')) as title_word +where d.owner_id is null and d.status = 'indexed' + and length(title_word) between 4 and 40 +on conflict do nothing; + create or replace function public.correct_clinical_query_terms( input_query text, min_sim real default 0.45 @@ -5191,6 +5191,8 @@ grant select, insert, update, delete on table public.audit_logs to service_role; grant usage, select on all sequences in schema public to service_role; grant execute on all functions in schema public to service_role; +revoke execute on function public.cleanup_registry_corpus_document() from service_role; +revoke execute on function public.sync_document_title_words() from service_role; revoke execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) from public, anon, authenticated; grant execute on function public.claim_indexing_v3_agent_jobs(text, integer, integer) to service_role; revoke execute on function public.match_document_embedding_fields_text(text, integer, double precision, uuid[], uuid) from public, anon, authenticated; diff --git a/tests/owner-catalogue-cache.test.ts b/tests/owner-catalogue-cache.test.ts index 06d0768fd..7b80cfae8 100644 --- a/tests/owner-catalogue-cache.test.ts +++ b/tests/owner-catalogue-cache.test.ts @@ -192,4 +192,19 @@ describe("owner catalogue cache", () => { expect(ownerService).toHaveBeenCalledTimes(1); expect(otherMedication).toHaveBeenCalledTimes(1); }); + + it("preserves the seeding flight while invalidating other medication loads", async () => { + const { invalidateOwnerCatalogueCache, loadOwnerCatalogue } = await loadCacheModule(); + const load = vi.fn(async (signal: AbortSignal) => { + invalidateOwnerCatalogueCache({ ownerId: "owner-a", kind: "medication", preserveSignal: signal }); + return [{ slug: "clozapine" }]; + }); + + await expect(loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load })).resolves.toEqual([ + { slug: "clozapine" }, + ]); + await loadOwnerCatalogue({ ownerId: "owner-a", kind: "medication", limit: 500, load }); + + expect(load).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 736b9ffc3..ae19ad0c0 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -1247,6 +1247,9 @@ describe("Supabase Preview replay guards", () => { expect(sql).toContain( "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); @@ -1258,6 +1261,17 @@ describe("Supabase Preview replay guards", () => { 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", () => { diff --git a/tests/test-runner-safety.test.ts b/tests/test-runner-safety.test.ts index 1e39508d0..745305d39 100644 --- a/tests/test-runner-safety.test.ts +++ b/tests/test-runner-safety.test.ts @@ -184,4 +184,11 @@ describe("provider-safe test environment", () => { expect(baseUrl.indexOf("if (!allowEnsure)")).toBeLessThan(baseUrl.indexOf("findExistingLocalProjectUrl();")); expect(ragRunner).toContain("cwd: projectRoot"); }); + + it("uses webpack when shared worktree dependencies resolve outside the project", () => { + const devRunner = readFileSync(new URL("../scripts/dev-free-port.mjs", import.meta.url), "utf8"); + expect(devRunner).toContain('fs.realpathSync(path.join(projectRoot, "node_modules"))'); + expect(devRunner).toContain('return dependenciesAreExternal ? ["--webpack"] : [];'); + expect(devRunner).toContain('args.some((arg) => ["--webpack", "--turbopack", "--turbo"].includes(arg))'); + }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index af50bc122..22afdb461 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2849,6 +2849,33 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(documentResults).toContainText("Best match"); }); + test("dashboard defers source and administration requests until their surfaces open @critical", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + const requestCounts = { documents: 0, jobs: 0, batches: 0, quality: 0 }; + page.on("request", (request) => { + const pathname = new URL(request.url()).pathname; + if (pathname === "/api/documents") requestCounts.documents += 1; + if (pathname === "/api/ingestion/jobs") requestCounts.jobs += 1; + if (pathname === "/api/ingestion/batches") requestCounts.batches += 1; + if (pathname === "/api/ingestion/quality") requestCounts.quality += 1; + }); + + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + expect(requestCounts).toEqual({ documents: 0, jobs: 0, batches: 0, quality: 0 }); + + await switchToDocumentSearchMode(page); + await page + .getByRole("button", { name: /Browse library/i }) + .first() + .click(); + await expect.poll(() => requestCounts.documents).toBeGreaterThan(0); + expect(requestCounts.jobs).toBe(0); + expect(requestCounts.batches).toBe(0); + expect(requestCounts.quality).toBe(0); + }); + test("tools mode searches the existing applications registry inside the dashboard", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockPrivateUnauthenticatedApi(page); @@ -2893,12 +2920,12 @@ test.describe("Clinical KB UI smoke coverage", () => { "Patient safety plan should include", ); await expect( - page.getByTestId("desktop-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), + page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), ).toBeVisible(); const sourceSearch = page.getByLabel("Search within indexed source text").last(); await sourceSearch.fill("safety plan include"); - const desktopTextPanel = page.getByTestId("desktop-chunk-indexed-text-panel"); + const desktopTextPanel = page.getByTestId("source-chunk-indexed-text-panel"); await expect(desktopTextPanel.getByText("Hit 1 of 2").first()).toBeVisible(); const previousHit = desktopTextPanel.getByRole("button", { name: "Previous document search hit" }); const nextHit = desktopTextPanel.getByRole("button", { name: "Next document search hit" }); @@ -2912,6 +2939,49 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("document viewer hydrates once and signs downloads only on demand @critical", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + + const documentId = "11111111-1111-4111-8111-111111111111"; + const browserDetailRequests: string[] = []; + const setupRequests: string[] = []; + const signedUrlRequests: Array<"preview" | "download"> = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname === `/api/documents/${documentId}`) browserDetailRequests.push(request.url()); + if (url.pathname === "/api/setup-status") setupRequests.push(request.url()); + }); + await page.route(/\/api\/documents\/([^/]+)\/signed-url(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const id = url.pathname.split("/").at(-2) ?? ""; + const document = getDemoDocument(id); + if (!document) { + await route.fulfill({ status: 404, json: { error: "Demo document not found." } }); + return; + } + const requestKind = url.searchParams.get("download") === "true" ? "download" : "preview"; + signedUrlRequests.push(requestKind); + if (requestKind === "download") await new Promise((resolve) => setTimeout(resolve, 250)); + await route.fulfill({ + json: { url: document.storage_path, fileType: document.file_type, demoMode: true }, + }); + }); + + await gotoApp(page, `/documents/${documentId}?page=1&chunk=44444444-4444-4444-8444-444444444442`); + await expect(page.getByRole("heading", { level: 1, name: "Synthetic lithium monitoring protocol" })).toBeVisible(); + await expect(page.getByTestId("source-chunk-indexed-text-panel")).toHaveCount(1); + await expect.poll(() => signedUrlRequests.filter((kind) => kind === "preview").length).toBe(1); + expect(browserDetailRequests).toHaveLength(0); + expect(setupRequests).toHaveLength(0); + expect(signedUrlRequests.filter((kind) => kind === "download")).toHaveLength(0); + + const downloadButton = page.getByRole("button", { name: "Download", exact: true }); + await expect(downloadButton).toBeEnabled(); + await downloadButton.dblclick(); + await expect.poll(() => signedUrlRequests.filter((kind) => kind === "download").length).toBe(1); + }); + test("document viewer puts the PDF preview first with pinned evidence after it on mobile", async ({ page }) => { await page.setViewportSize({ width: 320, height: 720 }); await mockDemoApi(page); @@ -2928,9 +2998,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(evidence).toBeVisible(); await expect(evidence.getByText("Highlighted source passage")).toBeVisible(); - await expect(page.locator("#source-text-mobile")).toHaveJSProperty("open", true); + await expect(page.locator("#source-text")).toBeVisible(); await expect( - page.getByTestId("mobile-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), + page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), ).toBeVisible(); await expect(viewerNav.getByRole("link", { name: "Evidence" })).toBeVisible(); await expect(viewerNav.getByRole("link", { name: "PDF" })).toBeVisible(); @@ -3063,13 +3133,14 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { level: 1, name: "Synthetic lithium monitoring protocol" })).toBeVisible({ timeout: 30_000, }); - const indexedText = page.locator("#source-text-mobile"); + const indexedText = page.locator("#source-text"); const summary = page.getByTestId("high-yield-summary"); const images = page.locator("#source-images"); const indexingDetails = page.getByTestId("indexing-details"); const viewerNav = page.getByRole("navigation", { name: "Document viewer sections" }).first(); - for (const disclosure of [indexedText, summary, images, indexingDetails]) { + await expect(indexedText).toBeVisible(); + for (const disclosure of [summary, images, indexingDetails]) { await expect(disclosure).toHaveJSProperty("open", false); } @@ -3086,12 +3157,12 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(images).toHaveJSProperty("open", true); await viewerNav.getByRole("link", { name: "Text" }).click(); - await expect(indexedText).toHaveJSProperty("open", true); + await expect(indexedText).toBeInViewport(); await expect(images).toHaveJSProperty("open", false); await viewerNav.getByRole("link", { name: "Summary" }).click(); await expect(summary).toHaveJSProperty("open", true); - await expect(indexedText).toHaveJSProperty("open", false); + await expect(indexedText).toBeVisible(); await viewerNav.getByRole("link", { name: "Images" }).click(); await expect(images).toHaveJSProperty("open", true); diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index 821fa4176..fabbafa1a 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page, type Route } from "playwright/test"; // Cross-entity universal typeahead in the command surface. The universal endpoint is // mocked so this spec exercises the UI contract (grouped sections, navigation, @@ -74,6 +74,15 @@ const universalPayload = { ], }; +async function fulfillUniversalSearch(route: Route, response: typeof universalPayload & Record) { + const query = response.query; + const events = [...response.groups.map((group) => ({ type: "group", query, group })), { type: "complete", response }]; + await route.fulfill({ + body: `${events.map((event) => JSON.stringify(event)).join("\n")}\n`, + contentType: "application/x-ndjson; charset=utf-8", + }); +} + async function mockUniversalSearch(page: Page) { await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { const mode = new URL(route.request().url()).searchParams.get("mode") ?? "documents"; @@ -90,13 +99,11 @@ async function mockUniversalSearch(page: Page) { }; const preferredDomains = preferredByMode[mode] ?? []; const responseOrder = universalPayload.groups.map((group) => group.kind); - await route.fulfill({ - json: { - ...universalPayload, - contextMode: mode, - preferredDomains, - domainOrder: [...preferredDomains, ...responseOrder.filter((domain) => !preferredDomains.includes(domain))], - }, + await fulfillUniversalSearch(route, { + ...universalPayload, + contextMode: mode, + preferredDomains, + domainOrder: [...preferredDomains, ...responseOrder.filter((domain) => !preferredDomains.includes(domain))], }); }); } @@ -127,14 +134,13 @@ test.describe("universal search typeahead", () => { test("does not count document-only hits as visible Medication rows", async ({ page }) => { await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { - await route.fulfill({ - json: { - query: "prescribing policy", - contextMode: "documents", - preferredDomains: ["documents"], - domainOrder: ["documents"], - groups: [universalPayload.groups[0]], - }, + await fulfillUniversalSearch(route, { + ...universalPayload, + query: "prescribing policy", + contextMode: "documents", + preferredDomains: ["documents"], + domainOrder: ["documents"], + groups: [universalPayload.groups[0]], }); }); const input = await openComposer(page); @@ -261,7 +267,7 @@ const smartPayload = { test.describe("universal search smart affordances", () => { async function mockSmartSearch(page: Page) { await page.route(/\/api\/search\/universal(?:\?.*)?$/, async (route) => { - await route.fulfill({ json: smartPayload }); + await fulfillUniversalSearch(route, smartPayload); }); await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { const answer = { From 58bf0f042b4759c687fdbc3104dea7726e1215de Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:49:51 +0800 Subject: [PATCH 3/8] docs: record performance remediation review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index fff042348..1c1b84596 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -574,3 +574,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-17 | PR #713 / codex/chat-workflow-ideas-0916 | b52112df6aa36311d7420189064acd79dcf2c3f5 + reviewed follow-up diff | workflow toolkit review follow-up | Fixed all 14 actionable Codex and CodeRabbit threads: cross-platform path fixtures, installation-managed preflight guidance, complete Supabase-backed API database scoping, per-command approval boundaries, plugin-ignore narrowing, isolated CI-scope proof, remote-Git command guarding, repository-skill verification classification, `TypeError` diagnosis, strict CLI option values, machine-parseable JSON evidence output, and preservation of baseline database/clinical approval gates in the RAG lab. No unresolved actionable finding remains in the reviewed scope. | `npm run verify:cheap` passed with 273 files and 2,599 tests; focused toolkit Vitest 20/20; CI-scope self-test; plugin-ignore proof; `git diff --check`. Exact-head hosted CI remains required after the follow-up push. No Supabase, OpenAI, or other live product-provider command was run. | | 2026-07-17 | PR #699 / codex/test-reliability-hardening | 6202835ab1cf3703af311b9afdf372f74c63e040 | branch-cleanup-superseded | Closed as fully superseded by merged PR #705 (`e5caaa46c`). Range-diff maps the original implementation commit to #705's first integration commit; #705 then adds seven focused reliability fixes, while #699's remaining commit is merge-only and contributes no unique relevant patch. The exact-SHA remote ref and the unregistered local predecessor ref (`b518c1de9`) were deleted after final rechecks. | Fresh GitHub PR/head/status inventory; exact `ls-remote` and local-ref checks; cherry-pick-aware log; range-diff against PR #705's merged head; merge-parent verification; exact leased remote deletion and exact-old-value local `update-ref` deletion. No Supabase/OpenAI/product-provider checks run. | | 2026-07-17 | PR #716 / codex/pr-process-hardening-20260717 | 220de891f10f82df11eb2e8137367514c139a206 + reviewed implementation/follow-up diff | PR metadata, CI triage, review routing, Actions permissions, secret scanning, and branch-hygiene hardening | No remaining high-confidence P0-P2 defect in the reviewed process diff. Added a trusted base-SHA PR metadata/risk policy with draft and merge-queue handling; corrected CI triage to compare only the same workflow's latest completed `main` run; wired self-tests into local/hosted gates; documented the policy; created the missing durable Codex routing labels; removed four unused per-head routing labels; and applied read-only default Actions tokens, immutable Action pinning, no Actions-authored approvals, push protection, and automatic merged-branch deletion. Four policy defects were fixed before merge: API-only paths no longer trigger UI evidence, slash-bearing outcome titles are accepted, all seven governance items are required exactly, and placeholder risk/rollback text is rejected. | Offline `check:pr-policy`, `check:ci-triage`, `check:ci-scope`, action-pin check, scoped Prettier, and `git diff --check` passed. Initial hosted CI passed static, safety, coverage, build, images, Semgrep, Gitleaks, GitGuardian, and the aggregate gate; exact-head CI is required after the review fix. Broader local gates were deferred because other registered worktrees repeatedly held the heavyweight lock. GitHub provider inspection/settings changes were explicitly authorized. No Supabase/OpenAI/product-provider command ran. | +| 2026-07-17 | codex/performance-latency-remediation-20260717 | f277d13512e85dcaae4f9b94d028a8087e41375a | end-to-end database, network, middleware, client rendering, migration, privacy, and merge-readiness review | Fixed all confirmed performance findings plus three final review blockers: title-vocabulary backfill now installs its privacy trigger first, canonical schema replay preserves hardened trigger-function ACLs, and medication auto-seeding no longer aborts its own owner-cache flight. The isolated worktree also uses Next's supported Webpack fallback only when shared dependencies resolve outside the project. No remaining high-confidence P0-P2 defect was found in the reviewed diff. | Docker scratch replay and drift-manifest regeneration passed; live read-only plans selected lexical, table-fact trigram, and chunk HNSW indexes; exact TypeScript passed; focused Vitest owner-cache 9/9, runner safety 12/12, bundle budget 10/10, offline RAG 291/291, and earlier aggregate unit run 2,657 passed with its sole corrected schema assertion subsequently validated by direct ordering/ACL checks; production build and bundle budget passed at 1,363,382 gzip bytes; targeted Chromium dashboard deferral and NDJSON search passed, while the viewer trace reached all hydration/preview assertions and its corrected download-control selector remains for exact-head hosted UI. No OpenAI calls, live Supabase writes/migrations, deployment, or production mutation ran. | From d47ef7a329256687a615c33dd311806c2c1214a8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:18:48 +0800 Subject: [PATCH 4/8] fix: close performance merge blockers --- ...r-apply-performance-latency-remediation.md | 2 +- src/components/ClinicalDashboard.tsx | 4 +- src/components/DocumentViewer.tsx | 5 +++ supabase/drift-manifest.json | 4 +- ...717170000_registry_projection_cleanup.sql} | 0 ...20260717171000_public_title_corrector.sql} | 0 ...0717172000_atomic_summary_rate_limits.sql} | 0 tests/supabase-schema.test.ts | 6 +-- tests/ui-smoke.spec.ts | 40 ++++++++++--------- 9 files changed, 35 insertions(+), 26 deletions(-) rename supabase/migrations/{20260717130000_registry_projection_cleanup.sql => 20260717170000_registry_projection_cleanup.sql} (100%) rename supabase/migrations/{20260717131000_public_title_corrector.sql => 20260717171000_public_title_corrector.sql} (100%) rename supabase/migrations/{20260717132000_atomic_summary_rate_limits.sql => 20260717172000_atomic_summary_rate_limits.sql} (100%) diff --git a/docs/operator-apply-performance-latency-remediation.md b/docs/operator-apply-performance-latency-remediation.md index d39678c72..9cb1faf3a 100644 --- a/docs/operator-apply-performance-latency-remediation.md +++ b/docs/operator-apply-performance-latency-remediation.md @@ -5,7 +5,7 @@ explicitly authorized operation after local replay, review, and backups. ## Registry projection index on a busy database -`20260717130000_registry_projection_cleanup.sql` creates +`20260717170000_registry_projection_cleanup.sql` creates `documents_registry_projection_lookup_idx` transactionally so clean local replay remains deterministic. On a busy production database, pre-create the exact index outside a transaction: diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 623187b84..322216daf 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -648,6 +648,7 @@ export function ClinicalDashboard({ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useSidebarCollapsed(); const [documentsDrawerOpen, setDocumentsDrawerOpen] = useState(false); + const [documentScopeOpen, setDocumentScopeOpen] = useState(false); const [documentsDrawerMode, setDocumentsDrawerMode] = useState("library"); const [uploadDrawerOpen, setUploadDrawerOpen] = useState(false); const [uploadMobileTab, setUploadMobileTab] = useState("upload"); @@ -1513,7 +1514,7 @@ export function ClinicalDashboard({ [documents, jobs, batches, indexingActive], ); const needsSetupRecheck = useMemo(() => setupNeedsSlowRecheck(setupChecks), [setupChecks]); - const dashboardDataSurfaceVisible = documentsDrawerOpen || uploadDrawerOpen; + const dashboardDataSurfaceVisible = documentScopeOpen || documentsDrawerOpen || uploadDrawerOpen; const administrationSurfaceVisible = uploadDrawerOpen || (documentsDrawerOpen && documentsDrawerMode === "admin"); useEffect(() => { @@ -3403,6 +3404,7 @@ export function ClinicalDashboard({ onClearScope={() => setSelectedDocumentIds([])} onQueryModeChange={setQueryMode} onScopeFiltersChange={setScopeFilters} + onScopeOpenChange={setDocumentScopeOpen} onToggleScope={toggleDocumentScope} onOpenUpload={openUploadDrawer} onOpenEvidence={openEvidenceDrawer} diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 35e08602a..f00679487 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -600,6 +600,11 @@ function DocumentViewerAnchors({ href={anchor.href} onClick={() => { const target = window.document.querySelector(anchor.href); + window.document + .querySelectorAll('details[name="document-viewer-section"]') + .forEach((disclosure) => { + if (disclosure !== target) disclosure.open = false; + }); if (target instanceof HTMLDetailsElement) target.open = true; }} className="inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-xs font-semibold text-[color:var(--clinical-accent)] shadow-[var(--shadow-tight)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index 9ebd1831b..898bb4b0a 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -1,9 +1,9 @@ { - "generated_at": "2026-07-17T13:13:55.034Z", + "generated_at": "2026-07-17T14:17:47.678Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", "schema_sha256": "cea1a026138298931993d5bd5771c1c96b94ff77e9edaa88998cea3f1a3fb1b4", - "replay_seconds": 100, + "replay_seconds": 15, "snapshot": { "views": [ { diff --git a/supabase/migrations/20260717130000_registry_projection_cleanup.sql b/supabase/migrations/20260717170000_registry_projection_cleanup.sql similarity index 100% rename from supabase/migrations/20260717130000_registry_projection_cleanup.sql rename to supabase/migrations/20260717170000_registry_projection_cleanup.sql diff --git a/supabase/migrations/20260717131000_public_title_corrector.sql b/supabase/migrations/20260717171000_public_title_corrector.sql similarity index 100% rename from supabase/migrations/20260717131000_public_title_corrector.sql rename to supabase/migrations/20260717171000_public_title_corrector.sql diff --git a/supabase/migrations/20260717132000_atomic_summary_rate_limits.sql b/supabase/migrations/20260717172000_atomic_summary_rate_limits.sql similarity index 100% rename from supabase/migrations/20260717132000_atomic_summary_rate_limits.sql rename to supabase/migrations/20260717172000_atomic_summary_rate_limits.sql diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index ae19ad0c0..4226f8641 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -169,15 +169,15 @@ const responseCacheRetentionReconciliationMigration = readFileSync( "utf8", ).replace(/\s+/g, " "); const registryProjectionCleanupMigration = readFileSync( - new URL("../supabase/migrations/20260717130000_registry_projection_cleanup.sql", import.meta.url), + new URL("../supabase/migrations/20260717170000_registry_projection_cleanup.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const publicTitleCorrectorMigration = readFileSync( - new URL("../supabase/migrations/20260717131000_public_title_corrector.sql", import.meta.url), + new URL("../supabase/migrations/20260717171000_public_title_corrector.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const atomicSummaryRateLimitsMigration = readFileSync( - new URL("../supabase/migrations/20260717132000_atomic_summary_rate_limits.sql", import.meta.url), + new URL("../supabase/migrations/20260717172000_atomic_summary_rate_limits.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); const liveDatabaseDriftMigration = readFileSync( diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 22afdb461..e61b4eda3 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -2865,12 +2865,19 @@ test.describe("Clinical KB UI smoke coverage", () => { await waitForDemoDashboardReady(page); expect(requestCounts).toEqual({ documents: 0, jobs: 0, batches: 0, quality: 0 }); + await openScopeControl(page); + await expect.poll(() => requestCounts.documents).toBe(1); + expect(requestCounts.jobs).toBe(0); + expect(requestCounts.batches).toBe(0); + expect(requestCounts.quality).toBe(0); + await page.keyboard.press("Escape"); + await switchToDocumentSearchMode(page); await page .getByRole("button", { name: /Browse library/i }) .first() .click(); - await expect.poll(() => requestCounts.documents).toBeGreaterThan(0); + await expect.poll(() => requestCounts.documents).toBe(1); expect(requestCounts.jobs).toBe(0); expect(requestCounts.batches).toBe(0); expect(requestCounts.quality).toBe(0); @@ -2914,10 +2921,10 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(page.getByRole("heading", { name: "No matching documents" }).first()).toBeVisible(); const demoDocId = "11111111-1111-4111-8111-111111111111"; - await gotoApp(page, `/documents/${demoDocId}?chunk=55555555-5555-4555-8555-555555555555`); - await expect(page).toHaveURL(/chunk=55555555-5555-4555-8555-555555555555/); + await gotoApp(page, `/documents/${demoDocId}?chunk=44444444-4444-4444-8444-444444444442`); + await expect(page).toHaveURL(/chunk=44444444-4444-4444-8444-444444444442/); await expect(page.locator("#source-evidence").getByTestId("highlighted-source-passage")).toContainText( - "Patient safety plan should include", + "Escalate review when there is vomiting", ); await expect( page.getByTestId("source-chunk-indexed-text-panel").getByTestId("highlighted-indexed-source-chunk"), @@ -2927,6 +2934,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await sourceSearch.fill("safety plan include"); const desktopTextPanel = page.getByTestId("source-chunk-indexed-text-panel"); await expect(desktopTextPanel.getByText("Hit 1 of 2").first()).toBeVisible(); + await expect(desktopTextPanel.locator("mark").filter({ hasText: "safety" }).first()).toBeVisible(); const previousHit = desktopTextPanel.getByRole("button", { name: "Previous document search hit" }); const nextHit = desktopTextPanel.getByRole("button", { name: "Next document search hit" }); await expect(previousHit).toHaveAttribute("title", "Previous document search hit"); @@ -2935,7 +2943,6 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(nextHit).toHaveText(""); await nextHit.click(); await expect(desktopTextPanel.getByText("Hit 2 of 2")).toBeVisible(); - await expect(desktopTextPanel.locator("mark").filter({ hasText: "safety" }).first()).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); @@ -3023,7 +3030,10 @@ test.describe("Clinical KB UI smoke coverage", () => { const evidenceBox = await evidence.boundingBox(); const previewBox = await preview.boundingBox(); - const indexedTextBox = await page.getByText("Indexed page text", { exact: true }).boundingBox(); + const indexedTextHeading = page + .getByTestId("source-chunk-indexed-text-panel") + .getByRole("heading", { name: "Indexed source text", exact: true }); + const indexedTextBox = await indexedTextHeading.boundingBox(); const imagesBox = await page.getByRole("heading", { name: "Tables and diagrams" }).boundingBox(); expect(evidenceBox).not.toBeNull(); @@ -3047,7 +3057,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await activateFocusedControl(page, viewerNav.getByRole("link", { name: "PDF" })); await expect(preview).toBeInViewport(); await activateFocusedControl(page, viewerNav.getByRole("link", { name: "Text" })); - await expect(page.getByText("Indexed page text", { exact: true })).toBeInViewport(); + await expect(indexedTextHeading).toBeInViewport(); await activateFocusedControl(page, viewerNav.getByRole("link", { name: "PDF" })); await expect(preview).toBeInViewport(); @@ -3513,15 +3523,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("document viewer private missing source state is coherent", async ({ page }) => { + test("document viewer missing source state is coherent", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockPrivateUnauthenticatedApi(page); - await page.route(/\/api\/documents\/[^/]+(?:\?.*)?$/, async (route) => { - await route.fulfill({ - status: 404, - json: { error: "Document not found." }, - }); - }); await page.route(/\/api\/documents\/[^/]+\/signed-url(?:\?.*)?$/, async (route) => { await route.fulfill({ status: 404, @@ -3530,15 +3534,13 @@ test.describe("Clinical KB UI smoke coverage", () => { }); await gotoApp( page, - "/documents/11111111-1111-4111-8111-111111111111?page=1&chunk=44444444-4444-4444-8444-444444444442", + "/documents/99999999-9999-4999-8999-999999999999?page=1&chunk=99999999-9999-4999-8999-999999999998", ); - await expect(page.getByRole("heading", { level: 1, name: /Sign in required|Source unavailable/ })).toBeVisible({ + await expect(page.getByRole("heading", { level: 1, name: "Source unavailable" })).toBeVisible({ timeout: 30000, }); - await expect(page.locator("body")).toContainText( - /Sign in to open private source documents\.|Document not found\.|Supabase browser authentication is not configured for private source documents\./, - ); + await expect(page.locator("body")).toContainText(/Demo document not found\./i); await expect(page.getByRole("button", { name: /^Answer from this(?: document)?$/ }).first()).toBeDisabled(); await expect(page.locator("body")).not.toContainText("loading source"); await expect(page.locator("body")).not.toContainText("Loading source metadata"); From dfae1662503f98c9abcdc6af1f316a2436b5ee77 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:20:43 +0800 Subject: [PATCH 5/8] docs: record performance merge follow-up --- docs/branch-review-ledger.md | 77 ++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 1c1b84596..a8b24fcc0 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,44 +18,45 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | ------------------------------------------------------ | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-07-13 | codex/lithium-answer-recovery-pr | c5fde11e64d8976e1c163d1b8618f58a52e0b8ff | lithium answer recovery and source governance | Fixed the provider-failure path with a grounded Australian source-backed fallback, a public-safe progress lifecycle, centralised Australian authority/context selection, and fail-closed locality repair. The live audit exposed and the branch fixed hierarchy identity false conflicts plus registry projections entering clinical metadata gates. The corrected audit/backfill found zero proposals, so no production write was made. No high-confidence defect remains. Residual risk is provider latency; live generation timed out but the grounded fallback completed. | `npm run verify:pr-local` passed: 1,988 tests passed/1 skipped, build/client scan, 36 fixtures, and 267 offline RAG tests. After review fixes, `npm run verify:cheap` passed with 1,992 tests passed/1 skipped. `npm run check:production-readiness` passed 5/5. `npm run test:e2e:critical` passed 9/9. `node scripts/run-playwright.mjs tests/answer-progress-ui-smoke.spec.ts --project=chromium` passed 2/2. `node scripts/run-eval-safe.mjs scripts/eval-rag.ts --question "Lithium dosing" --expect-australian --fail-on-threshold --json` exited 0 with one grounded FSH citation and no threshold/safety failures. `npm run audit:source-governance` reported 0 gaps/conflicts/proposals across 2,851 rows. `npm run backfill:source-metadata -- --locality-only` reported 0 changes. `git diff --check` passed. Full `npm run verify:ui` was not rerun after the aggregate runner lost its local server. | -| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval | -| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` | -| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | -| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | -| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | -| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | -| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | -| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | -| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` | -| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check | -| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | -| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | -| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | -| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | -| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | -| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | -| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. | -| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | -| 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. | -| 2026-07-13 | codex/repository-review-remediation | 452275824294564a1e08e6bec169bd4af744d09a | live migration apply and post-apply review | Applied the four reviewed forward migrations to `Clinical KB Database`, aligned repository filenames to the generated production versions, and corrected the schema snapshot so the legacy unfenced commit overload remains inaccessible to `service_role`. Live drift is clean and no active ingestion/enrichment overlap or duplicate open ingestion group was found. | Ran `npm run check:drift`: passed clean. Ran `npm run check:production-readiness`: READY. Ran Docker schema replay: passed. Ran focused concurrency/retrieval Vitest: 166/166 passed. Ran offline RAG: 36 fixtures and 60/60 contract tests passed. Ran M13, retrieval-owner, schema-health, lexical-retrieval, concurrency, and ACL live probes: passed; lexical retrieval returned 12 truthfully scored results. Not completed: full provider retrieval-quality evaluation exceeded the local command window; deterministic live retrieval checks passed. | -| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. | -| 2026-07-13 | codex/public-anonymous-access | 7f3eded3d17c9daf6a443c9cac3f0553e4e9321b | production UI design and accessibility review | Fixed the fullscreen clinical-table focus leak and divergent modal implementation, removed the non-native table-surface control, and lifted meaningful production metadata from 8-10px to the 11px floor with stronger muted contrast. No remaining high-confidence defect was found in the reviewed visual scope. | Baseline/final screenshots at 1440x1000 and 390x820; focused Chromium table expansion 3/3; focused Vitest 6/6; `npm run typecheck`; targeted ESLint; type-scale and focused Prettier checks; `git diff --check`. `verify:cheap` timed out in full lint/test execution; full `verify:ui` deferred under the API confirmation boundary. | -| 2026-07-13 | main (PR #570 squash, glass header) | cc6bfc1c80902ca3c91e5ba2ebe78a80f3fd9e14 | post-merge review: CSS/visual/a11y/perf + logic/regression | No P0/P1. P2s confirmed and fixed in follow-up: build pipeline dropped ALL hand-authored backdrop-filter declarations (manual -webkit- duplicates confused Lightning CSS — header scrim, bottom dock, and composer pill were tint-only in every engine); scrim retuned to carry the bar's frost alone (header backdrop-root removed) with masks fading to true zero; private-scope alert made sticky inside main (was scroll-away in non-answer modes); scroll-hide reporter reset on breakpoint-gate change; non-forced fallback backgrounds layered to preserve the utility-wins contract. | Ran custom Playwright probe (`node scratchpad/probe-blur.mjs`, Chromium 390x844): baseline showed `getComputedStyle(.edge-glass-header-backdrop).backdropFilter === "none"` on all three passes, after fix `blur(14px)/blur(20px)/blur(26px)` — passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium -g "glass header\|collapse hide\|private-scope alert\|phone (short\|long) answer stays"`: 6/6 passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium` (full file): 71 passed, 4 failed (pre-existing `/privacy` heading test, fails identically on clean main); Ran `npm run verify:cheap`: exit 0 (1935 unit tests passed); Ran `npm run format:check`: passed; Not run: WebKit/Safari real-device check (no WebKit runner in this environment — served client chunk verified to pair `-webkit-backdrop-filter` with each declaration for Safari <= 17) | -| 2026-07-13 | codex/rag-review-followup | 755ac9e517a3b81f8e12a119f80f3769dd58ae4e | PR #575 post-merge review finding remediation | Fixed the P1 path that could combine a medication amount and route from separate chunks, expanded the shared explicit amount/route/frequency intent detector, corrected route-only failure classification, and added microgram-symbol coverage. Requested attributes must now be co-located with the medication subject before the text fast path is accepted. No additional high-confidence defect was found in the changed scope after integrating the production answer-budget fix from PR #580. | Focused Vitest 143/143; `npm run eval:rag:offline` (21 files, 265/265); `npm run typecheck`; targeted ESLint; full `npm test` (211 files passed, 1 skipped; 1,946 tests passed, 1 skipped); PR-local dry-run selected runtime, format, lint, typecheck, full tests, build, and offline RAG; `git diff --check`. `verify:cheap` passed all pre-test stages but its 10-minute host bound expired during the full suite; the same suite then passed independently with a longer bound. | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | -------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-07-17 | PR #718 / codex/performance-latency-remediation-20260717 | d47ef7a329256687a615c33dd311806c2c1214a8 | hosted UI and migration-order merge-blocker follow-up | Fixed both integration defects exposed by the exact-head UI run: the document scope surface now triggers the deferred, deduplicated catalogue load, and viewer navigation closes unrelated disclosures before opening or scrolling to the selected section. Updated SSR-aware browser fixtures without restoring the removed detail request. Renumbered all three new migrations after the latest production migration and regenerated the drift manifest. No remaining high-confidence P0-P2 defect was found in the follow-up diff. | Focused Vitest 65/65; scoped ESLint and Prettier; `git diff --check`; isolated production Webpack build and TypeScript; focused Chromium 7/7 including both stress viewports; Docker schema replay and drift-manifest regeneration passed in 15 seconds with unchanged schema SHA. No OpenAI calls or live Supabase DDL, migration, rate-limit RPC, or data write ran. | +| 2026-07-13 | codex/lithium-answer-recovery-pr | c5fde11e64d8976e1c163d1b8618f58a52e0b8ff | lithium answer recovery and source governance | Fixed the provider-failure path with a grounded Australian source-backed fallback, a public-safe progress lifecycle, centralised Australian authority/context selection, and fail-closed locality repair. The live audit exposed and the branch fixed hierarchy identity false conflicts plus registry projections entering clinical metadata gates. The corrected audit/backfill found zero proposals, so no production write was made. No high-confidence defect remains. Residual risk is provider latency; live generation timed out but the grounded fallback completed. | `npm run verify:pr-local` passed: 1,988 tests passed/1 skipped, build/client scan, 36 fixtures, and 267 offline RAG tests. After review fixes, `npm run verify:cheap` passed with 1,992 tests passed/1 skipped. `npm run check:production-readiness` passed 5/5. `npm run test:e2e:critical` passed 9/9. `node scripts/run-playwright.mjs tests/answer-progress-ui-smoke.spec.ts --project=chromium` passed 2/2. `node scripts/run-eval-safe.mjs scripts/eval-rag.ts --question "Lithium dosing" --expect-australian --fail-on-threshold --json` exited 0 with one grounded FSH citation and no threshold/safety failures. `npm run audit:source-governance` reported 0 gaps/conflicts/proposals across 2,851 rows. `npm run backfill:source-metadata -- --locality-only` reported 0 changes. `git diff --check` passed. Full `npm run verify:ui` was not rerun after the aggregate runner lost its local server. | +| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval | +| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` | +| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | +| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | +| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | +| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | +| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | +| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | +| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` | +| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check | +| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | +| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | +| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | +| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | +| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | +| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | +| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. | +| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | +| 2026-07-13 | codex/repository-review-remediation | b72cefd2f5c0da79788cc0f8d0d40837c711ae92 | live-drift reconciliation and release review | Reconciled production migration history and live-ahead governance/retrieval definitions without mutating live; removed the migration-version collision; made captured OUT-signature changes fresh-replay-safe; preserved production ACLs; added a forward lexical-score correction; and reduced read-only live drift from 27 differences to five changes fully explained by the unapplied remediation migrations. No remaining high-confidence source defect was found in the reviewed scope. | Docker schema replay and regenerated manifest; isolated full Supabase migration reset; focused Vitest 74/74; full Vitest 1,712 passed/1 skipped; lint; typecheck; production build and client-bundle secret scan; configured production-readiness READY; targeted Chromium scope/modal/control QA 4/4; read-only live drift; Supabase security advisor clear. Live apply not run because authorization remained read-only. | +| 2026-07-13 | codex/repository-review-remediation | 452275824294564a1e08e6bec169bd4af744d09a | live migration apply and post-apply review | Applied the four reviewed forward migrations to `Clinical KB Database`, aligned repository filenames to the generated production versions, and corrected the schema snapshot so the legacy unfenced commit overload remains inaccessible to `service_role`. Live drift is clean and no active ingestion/enrichment overlap or duplicate open ingestion group was found. | Ran `npm run check:drift`: passed clean. Ran `npm run check:production-readiness`: READY. Ran Docker schema replay: passed. Ran focused concurrency/retrieval Vitest: 166/166 passed. Ran offline RAG: 36 fixtures and 60/60 contract tests passed. Ran M13, retrieval-owner, schema-health, lexical-retrieval, concurrency, and ACL live probes: passed; lexical retrieval returned 12 truthfully scored results. Not completed: full provider retrieval-quality evaluation exceeded the local command window; deterministic live retrieval checks passed. | +| 2026-07-13 | codex/fix-48h-review-findings-current | 49735663370735a60870d065ed0de3b9d34e077f | last-48-hours PR remediation | Revalidated the last-48-hours findings on current main after PRs #538 and #540; retained only unique fixes across auth/cache isolation, stale-response protection, upload/routing/UI behavior, RAG coalescing, telemetry, worktree tooling, and SAST enforcement. No remaining high-confidence local defect was found in the changed scope. The approved live drift check reported only the five differences already explained by unapplied migrations from #540. | Focused Vitest 107/107; `npm run verify:pr-local` (1,762 passed, 1 skipped; production build and client-bundle scan; offline RAG 60/60); critical Chromium 8/8; live `check:drift`; `git diff --check`. Full Chromium remains advisory after the earlier runner hang; the required critical subset passed on current main. | +| 2026-07-13 | codex/public-anonymous-access | 7f3eded3d17c9daf6a443c9cac3f0553e4e9321b | production UI design and accessibility review | Fixed the fullscreen clinical-table focus leak and divergent modal implementation, removed the non-native table-surface control, and lifted meaningful production metadata from 8-10px to the 11px floor with stronger muted contrast. No remaining high-confidence defect was found in the reviewed visual scope. | Baseline/final screenshots at 1440x1000 and 390x820; focused Chromium table expansion 3/3; focused Vitest 6/6; `npm run typecheck`; targeted ESLint; type-scale and focused Prettier checks; `git diff --check`. `verify:cheap` timed out in full lint/test execution; full `verify:ui` deferred under the API confirmation boundary. | +| 2026-07-13 | main (PR #570 squash, glass header) | cc6bfc1c80902ca3c91e5ba2ebe78a80f3fd9e14 | post-merge review: CSS/visual/a11y/perf + logic/regression | No P0/P1. P2s confirmed and fixed in follow-up: build pipeline dropped ALL hand-authored backdrop-filter declarations (manual -webkit- duplicates confused Lightning CSS — header scrim, bottom dock, and composer pill were tint-only in every engine); scrim retuned to carry the bar's frost alone (header backdrop-root removed) with masks fading to true zero; private-scope alert made sticky inside main (was scroll-away in non-answer modes); scroll-hide reporter reset on breakpoint-gate change; non-forced fallback backgrounds layered to preserve the utility-wins contract. | Ran custom Playwright probe (`node scratchpad/probe-blur.mjs`, Chromium 390x844): baseline showed `getComputedStyle(.edge-glass-header-backdrop).backdropFilter === "none"` on all three passes, after fix `blur(14px)/blur(20px)/blur(26px)` — passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium -g "glass header\|collapse hide\|private-scope alert\|phone (short\|long) answer stays"`: 6/6 passed; Ran `node scripts/run-playwright.mjs tests/ui-smoke.spec.ts --project=chromium` (full file): 71 passed, 4 failed (pre-existing `/privacy` heading test, fails identically on clean main); Ran `npm run verify:cheap`: exit 0 (1935 unit tests passed); Ran `npm run format:check`: passed; Not run: WebKit/Safari real-device check (no WebKit runner in this environment — served client chunk verified to pair `-webkit-backdrop-filter` with each declaration for Safari <= 17) | +| 2026-07-13 | codex/rag-review-followup | 755ac9e517a3b81f8e12a119f80f3769dd58ae4e | PR #575 post-merge review finding remediation | Fixed the P1 path that could combine a medication amount and route from separate chunks, expanded the shared explicit amount/route/frequency intent detector, corrected route-only failure classification, and added microgram-symbol coverage. Requested attributes must now be co-located with the medication subject before the text fast path is accepted. No additional high-confidence defect was found in the changed scope after integrating the production answer-budget fix from PR #580. | Focused Vitest 143/143; `npm run eval:rag:offline` (21 files, 265/265); `npm run typecheck`; targeted ESLint; full `npm test` (211 files passed, 1 skipped; 1,946 tests passed, 1 skipped); PR-local dry-run selected runtime, format, lint, typecheck, full tests, build, and offline RAG; `git diff --check`. `verify:cheap` passed all pre-test stages but its 10-minute host bound expired during the full suite; the same suite then passed independently with a longer bound. | | 2026-07-13 | origin/main (detached review worktree) | c523cabeae4b68ebdf569ecbc18d9f5a7b5afbf1 | repo-wide audit | Changes requested: one P1 clinical-answer trust cluster; three P2 tenancy/reindex guardrail issues; three P3 information-disclosure, dead-code, and transitive-deprecation cleanup items. No P0 found. | `npm run verify:cheap` (1,721 passed, 1 skipped); `npm run test:coverage` (thresholds passed); `npm run build`; `npm run verify:ui` (137/137); `npm run eval:rag:offline` (36 fixtures, 60 tests); format, Edge Function, Codex workflow, import/secret/dead-reference scans. Provider-backed checks skipped. | | 2026-07-13 | HEAD / origin/main (detached worktree) | 04c1d0b036cae8af4dabfc692055c7aab93d5888 | OpenAI-facing API and integration review | Read-only review found one P1 clinical-streaming defect and four P2 reliability/API-contract issues: provisional clinical prose is exposed before validation; mid-stream failure can silently trigger a second buffered generation; answer caches are not model/prompt fingerprinted; OpenAI access/model errors are under-classified; and table-fact route IDs are not validated before database access. GPT-5.6 migration also requires replacing the legacy prompt-cache parameter. No application code was changed. | Static call-flow, prompt, model, schema, streaming, cache, error, route, test, and governance inspection; official OpenAI model/Responses/structured-output/streaming/prompt-cache guidance reviewed. Provider-backed checks were not run. Local tests were not run because this worktree has no installed dependencies (`openai`, `vitest`). | From b5f509744d4f4bac74d414644cd1802f64b97fa9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:15:00 +0800 Subject: [PATCH 6/8] fix: resolve performance remediation review findings --- src/components/DocumentViewer.tsx | 114 +++++++----------- src/lib/document-detail.ts | 19 ++- src/lib/document-enrichment.ts | 2 + src/lib/rag.ts | 21 +++- src/lib/registry-seed.ts | 2 +- supabase/drift-manifest.json | 12 +- .../20260717171000_public_title_corrector.sql | 17 ++- ...60717172000_atomic_summary_rate_limits.sql | 27 +++-- ...sert_supabase_admin_default_privileges.sql | 10 +- supabase/schema.sql | 54 ++++++--- tests/client-performance-boundaries.test.ts | 16 ++- tests/document-detail-performance.test.ts | 9 ++ ...ocument-enrichment-retrieval-scope.test.ts | 23 ++++ tests/owner-catalogue-cache.test.ts | 8 ++ tests/rag-classifier-memo.test.ts | 26 ++++ tests/supabase-schema.test.ts | 38 ++++-- 16 files changed, 279 insertions(+), 119 deletions(-) diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index f00679487..178f93939 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1435,7 +1435,6 @@ function DocumentOverviewLanding({ document, initialPage, signedUrl, - downloadUrl, pages, pageHref, onPageChange, @@ -1448,7 +1447,6 @@ function DocumentOverviewLanding({ document: ClinicalDocument; initialPage: number; signedUrl: string | null; - downloadUrl: string | null; pages: PageRow[]; pageHref: (page: number) => string; onPageChange: (page: number) => void; @@ -1511,27 +1509,14 @@ function DocumentOverviewLanding({ Open preview )} - {downloadUrl ? ( - - Download - - ) : ( - - {downloading ? "Preparing" : "Download"} - - )} + + {downloading ? "Preparing" : "Download"} + )} - {downloadSignedUrl ? ( - setMobileActionsOpen(false)} - className={cn(secondaryButton, "min-h-12 justify-start text-xs")} - > + - )} + )} + {downloadingSource ? "Preparing PDF" : "Download PDF"} + )}
@@ -2797,16 +2770,19 @@ export function DocumentViewer({ )} {downloadSignedUrl && ( - void openSourceDownload()} + disabled={downloadingSource} className={secondaryButton} > - + {downloadingSource ? ( +