diff --git a/.gitleaksignore b/.gitleaksignore index 8b3bd8187..16fe1ab80 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -8,3 +8,5 @@ # generic-api-key rule on historical commit content scanned in PR history. 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:22253 27121756b10df6d93841ebf48b0f1991e0144a1f:data/medications-snapshot.json:generic-api-key:47690 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:21520 +b65acddaa13e69e0ad2a49783116056b3a1a3639:data/medications-snapshot.json:generic-api-key:46330 diff --git a/package.json b/package.json index c38c76383..aaaeefd4a 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,8 @@ "reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts", "supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts", "promote:query-misses": "tsx scripts/promote-query-misses.ts", + "promote:public-documents": "tsx scripts/promote-public-documents.ts", + "promote:public-documents:batch": "tsx scripts/promote-public-documents-batch.ts", "eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts", "eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts", "eval:quality": "node scripts/run-eval-safe.mjs scripts/eval-quality.ts", diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 0bde64faf..0c1749ceb 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -45,6 +45,7 @@ type EvalQualityArgs = { retrievalOnly: boolean; ragOnly: boolean; skipPreflight: boolean; + forceEmbedding: boolean; }; export type RagQualityResult = { @@ -150,6 +151,7 @@ function parseArgs(argv: string[]): EvalQualityArgs { retrievalOnly: false, ragOnly: false, skipPreflight: false, + forceEmbedding: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -176,6 +178,10 @@ function parseArgs(argv: string[]): EvalQualityArgs { args.skipPreflight = true; continue; } + if (token === "--force-embedding") { + args.forceEmbedding = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -476,6 +482,11 @@ export function buildEvalQualityReport(args: { `retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`, ); } + if (retrievalSummary.force_embedding_failure_count > 0) { + thresholdFailures.push( + `retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`, + ); + } if (governance.stale_rate > qualityThresholds.staleTopResultRate) { thresholdFailures.push( `top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`, @@ -664,6 +675,8 @@ ${markdownTable([ ## Retrieval Decision Metrics ${markdownTable([ + ["Force-embedding cases", retrieval.force_embedding_case_count], + ["Force-embedding failures", retrieval.force_embedding_failure_count], ["Embedding skipped rate", retrieval.embedding_skipped_rate], ["Median text candidate budget", retrieval.median_text_candidate_budget], ["Second-stage rerank rate", retrieval.second_stage_rerank_rate], @@ -728,6 +741,7 @@ async function runRetrievalQualityCases(args: { ownerId?: string; limit?: number; query?: string; + forceEmbedding?: boolean; supabase: Awaited>; }) { const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([ @@ -756,7 +770,7 @@ async function runRetrievalQualityCases(args: { topK: retrievalLimitForGoldenCase(testCase), minSimilarity: 0.12, skipCache: true, - forceEmbedding: testCase.forceEmbedding, + forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, }), ); const latencyMs = diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index edeb89564..2a46d96f2 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -47,6 +47,7 @@ type EvalArgs = { export type GoldenRetrievalResult = { id: string; query: string; + forceEmbedding: boolean; expectedQueryClass: string; actualQueryClass: string | null; expectedDocumentSubstrings: string[]; @@ -516,6 +517,11 @@ export function evaluateGoldenRetrievalCase(args: { const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK); const actualQueryClass = args.telemetry.query_class ?? null; const failures: string[] = []; + const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce( + (sum, [layer, count]) => + ["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum, + 0, + ); const hitAtK = documentHitsAtK.missing.length === 0 && contentHitsAtK.missing.length === 0 && @@ -533,10 +539,23 @@ export function evaluateGoldenRetrievalCase(args: { if (args.testCase.expectTableEvidence && !tableEvidenceFound) { failures.push("expected table evidence in top 5"); } + if (args.testCase.forceEmbedding) { + if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run"); + if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache"); + if ( + args.telemetry.retrieval_strategy === "text_fast_path" || + args.telemetry.retrieval_strategy === "document_lookup_fast_path" + ) { + failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`); + } + if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate"); + if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates"); + } return { id: args.testCase.id, query: args.testCase.query, + forceEmbedding: args.testCase.forceEmbedding ?? false, expectedQueryClass: args.testCase.expectedQueryClass, actualQueryClass, expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings, @@ -612,6 +631,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] } return counts; }, {}); + const forceEmbeddingResults = results.filter((result) => result.forceEmbedding); return { case_count: results.length, document_recall_at_5: Number( @@ -647,6 +667,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[] embedding_skip_reason_counts: embeddingSkipReasonCounts, text_fast_path_reason_counts: textFastPathReasonCounts, retrieval_layer_counts: layerCounts, + force_embedding_case_count: forceEmbeddingResults.length, + force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length, median_text_candidate_budget: percentile(textCandidateBudgets, 50), second_stage_rerank_rate: Number( (results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4), @@ -723,6 +745,8 @@ function printHumanSummary(summary: ReturnType>) { + const { count, error } = await supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .is("owner_id", null); + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function fetchBatchIds(supabase: Awaited>) { + const { data, error } = await supabase + .from("documents") + .select("id") + .eq("status", "indexed") + .not("owner_id", "is", null) + .in("metadata->>clinical_validation_status", ["locally_reviewed", "approved"]) + .limit(BATCH_SIZE); + if (error) throw new Error(error.message); + return (data ?? []).map((row) => row.id as string); +} + +async function promoteBatch(supabase: Awaited>, ids: string[]) { + if (ids.length === 0) return; + + const { error } = await supabase + .from("documents") + .update({ + owner_id: null, + updated_at: new Date().toISOString(), + }) + .in("id", ids); + if (error) throw new Error(error.message); +} + +async function main() { + const supabase = await loadAdminClient(); + let batches = 0; + + while (true) { + const ids = await fetchBatchIds(supabase); + if (ids.length === 0) break; + await promoteBatch(supabase, ids); + batches += 1; + const publicCount = await countPublic(supabase); + console.log( + `[public-documents:promote] batch ${batches}: promoted ${ids.length}; public indexed total ${publicCount}`, + ); + } + + console.log(`[public-documents:promote] complete. indexed public documents: ${await countPublic(supabase)}`); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/promote-public-documents.ts b/scripts/promote-public-documents.ts new file mode 100644 index 000000000..e7b1b606d --- /dev/null +++ b/scripts/promote-public-documents.ts @@ -0,0 +1,80 @@ +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.cwd()); + +type PromoteArgs = { + ownerId?: string; +}; + +async function loadAdminClient() { + const { createAdminClient } = await import("@/lib/supabase/admin"); + return createAdminClient(); +} + +function parseArgs(argv: string[]): PromoteArgs { + const args: PromoteArgs = { + ownerId: process.env.PUBLIC_WORKSPACE_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--owner-id") { + args.ownerId = argv[index + 1]; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + return args; +} + +async function countCandidates(supabase: Awaited>, ownerId?: string) { + let query = supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .not("owner_id", "is", null) + .in("metadata->>clinical_validation_status", ["locally_reviewed", "approved"]); + + if (ownerId) query = query.eq("owner_id", ownerId); + + const { count, error } = await query; + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function countPublic(supabase: Awaited>) { + const { count, error } = await supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .eq("status", "indexed") + .is("owner_id", null); + + if (error) throw new Error(error.message); + return count ?? 0; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const supabase = await loadAdminClient(); + + const [candidateCount, publicCount] = await Promise.all([ + countCandidates(supabase, args.ownerId), + countPublic(supabase), + ]); + + console.log("[public-documents:promote] indexed public documents:", publicCount); + console.log( + `[public-documents:promote] pending promotion${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`, + candidateCount, + ); + console.log( + "Apply migration 20260705220000_promote_locally_reviewed_documents_public.sql with `npx supabase db push --linked`.", + ); +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 8d4f3b2a9..55baf040b 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,7 +4,11 @@ import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { answerQuestionWithScope } from "@/lib/rag"; import { jsonError, PublicApiError } from "@/lib/http"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { classifyRagQuery } from "@/lib/clinical-search"; import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; @@ -70,7 +74,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "answer", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 4741c2287..b8509d102 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,7 +2,11 @@ import { z } from "zod"; import { demoAnswer } from "@/lib/demo-data"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError, jsonError } from "@/lib/http"; -import { consumeSubjectApiRateLimit, type ApiRateLimitResult } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + type ApiRateLimitResult, +} from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { answerQuestionWithScope, type AnswerProgressEvent } from "@/lib/rag"; import { classifyRagQuery } from "@/lib/clinical-search"; @@ -232,7 +236,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "answer", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) return rateLimitStream(rateLimit); diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index eaed5d200..abe35bbb2 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, normalizeDifferentialSlug, @@ -14,7 +18,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery } from "@/lib/validation/query"; @@ -63,7 +67,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const snapshot = loadDifferentialSnapshot(); const governance = deriveGovernanceFromSnapshot(snapshot); if (kind === "presentation") { @@ -91,7 +95,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index 239579032..85574d8fe 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, normalizeDifferentialSlug, @@ -13,7 +17,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -53,7 +57,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const snapshot = loadDifferentialSnapshot(); const workflow = getPresentationWorkflow(normalizedSlug); if (!workflow) return notFoundResponse(normalizedSlug); @@ -78,7 +82,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index 260e2a90a..2b467a47f 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { deriveGovernanceFromSnapshot, rowGovernance, @@ -14,7 +18,7 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { differentialRecords, searchDifferentialRecords, searchPresentationWorkflows } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -68,7 +72,7 @@ export async function GET(request: Request) { }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { return differentialResponse({ ...publicDifferentialPayload(kind, q, limit), publicAccess: true, @@ -82,7 +86,7 @@ export async function GET(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Differential requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index 55d6a1558..4ec7010ed 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -1,6 +1,7 @@ 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"; @@ -8,6 +9,7 @@ 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 { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { writeAuditLog } from "@/lib/audit"; import { parseJsonBody } from "@/lib/validation/body"; import { parseRouteParams } from "@/lib/validation/params"; @@ -280,13 +282,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentRouteParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("*") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + 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 }); diff --git a/src/app/api/documents/[id]/search/route.ts b/src/app/api/documents/[id]/search/route.ts index 4ab60b1ed..80deb18ae 100644 --- a/src/app/api/documents/[id]/search/route.ts +++ b/src/app/api/documents/[id]/search/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { demoChunks, getDemoDocument } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRouteParams } from "@/lib/validation/params"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -181,13 +183,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const { id } = parseRouteParams({ id: rawId }, documentSearchParamsSchema, "Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + 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: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); @@ -197,7 +200,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: p_document_id: id, p_query: query, match_count: limit, - p_owner_id: user.id, + p_owner_id: access.ownerId, }); if (!rpcError) { diff --git a/src/app/api/documents/[id]/signed-url/route.ts b/src/app/api/documents/[id]/signed-url/route.ts index 043d01118..7ecb96f47 100644 --- a/src/app/api/documents/[id]/signed-url/route.ts +++ b/src/app/api/documents/[id]/signed-url/route.ts @@ -1,11 +1,13 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoDocument } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,13 +33,14 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid document id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(_request, supabase); - const { data: document, error } = await supabase - .from("documents") - .select("storage_path,file_type") - .eq("id", id) - .eq("owner_id", user.id) - .maybeSingle(); + 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("storage_path,file_type").eq("id", id), + access.ownerId, + ).maybeSingle(); if (error) throw new Error(error.message); if (!document) return NextResponse.json({ error: "Document not found." }, { status: 404 }); diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 52814c3a6..eb6640817 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -1,14 +1,33 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { demoDocuments } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; import { parseRequestQuery, queryBoolean, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; +const PUBLIC_DOCUMENT_LIST_COLUMNS = [ + "id", + "owner_id", + "title", + "description", + "file_name", + "file_type", + "file_size", + "status", + "page_count", + "chunk_count", + "image_count", + "metadata", + "created_at", + "updated_at", +].join(","); + const DOCUMENT_LIST_COLUMNS = [ "id", "owner_id", @@ -31,6 +50,18 @@ const DOCUMENT_LIST_COLUMNS = [ "updated_at", ].join(","); +const PUBLIC_LABEL_LIST_COLUMNS = [ + "id", + "document_id", + "label", + "label_type", + "source", + "confidence", + "metadata", + "created_at", + "updated_at", +].join(","); + const LABEL_LIST_COLUMNS = [ "id", "document_id", @@ -44,6 +75,8 @@ const LABEL_LIST_COLUMNS = [ "updated_at", ].join(","); +const PUBLIC_SUMMARY_LIST_COLUMNS = ["id", "document_id", "summary", "clinical_specifics", "generated_at"].join(","); + const SUMMARY_LIST_COLUMNS = [ "id", "document_id", @@ -130,11 +163,14 @@ export async function GET(request: Request) { } = parseRequestQuery(request, documentListQuerySchema, "Invalid document list query."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(request, supabase); - let query = supabase - .from("documents") - .select(DOCUMENT_LIST_COLUMNS, { count: "exact" }) - .eq("owner_id", user.id) + const { access, rateLimit } = await enforceDocumentReadRateLimit(request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } + + const effectiveIncludeMeta = access.authenticated ? includeMeta : false; + const listColumns = access.authenticated ? DOCUMENT_LIST_COLUMNS : PUBLIC_DOCUMENT_LIST_COLUMNS; + let query = withOwnerReadScope(supabase.from("documents").select(listColumns, { count: "exact" }), access.ownerId) .order("created_at", { ascending: false }) .range(offset, offset + limit - 1); @@ -161,13 +197,15 @@ export async function GET(request: Request) { hasMore: count === null ? documents.length === limit : offset + documents.length < count, }; - if (documentIds.length === 0 || !includeMeta) { + if (documentIds.length === 0 || !effectiveIncludeMeta) { return documentsResponse({ documents, pagination }, indexing); } + const labelColumns = access.authenticated ? LABEL_LIST_COLUMNS : PUBLIC_LABEL_LIST_COLUMNS; + const summaryColumns = access.authenticated ? SUMMARY_LIST_COLUMNS : PUBLIC_SUMMARY_LIST_COLUMNS; const [labelsResult, summariesResult] = await Promise.all([ - supabase.from("document_labels").select(LABEL_LIST_COLUMNS).in("document_id", documentIds), - supabase.from("document_summaries").select(SUMMARY_LIST_COLUMNS).in("document_id", documentIds), + supabase.from("document_labels").select(labelColumns).in("document_id", documentIds), + supabase.from("document_summaries").select(summaryColumns).in("document_id", documentIds), ]); if (labelsResult.error) throw new Error(labelsResult.error.message); diff --git a/src/app/api/images/[id]/signed-url/route.ts b/src/app/api/images/[id]/signed-url/route.ts index b061aaf8a..c5fac4d63 100644 --- a/src/app/api/images/[id]/signed-url/route.ts +++ b/src/app/api/images/[id]/signed-url/route.ts @@ -1,12 +1,14 @@ import { NextResponse } from "next/server"; import { z } from "zod"; +import { rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { getDemoImage } from "@/lib/demo-data"; import { env } from "@/lib/env"; import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { committedIndexGeneration, isCommittedGenerationMetadata } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { enforceDocumentReadRateLimit, withOwnerReadScope } from "@/lib/public-api-access"; export const runtime = "nodejs"; @@ -31,7 +33,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (!routeIdSchema.safeParse(id).success) throw new PublicApiError("Invalid image id."); const supabase = createAdminClient(); - const user = await requireAuthenticatedUser(_request, supabase); + const { access, rateLimit } = await enforceDocumentReadRateLimit(_request, supabase); + if (rateLimit.limited) { + return rateLimitJsonResponse("Document requests are rate limited. Try again shortly.", rateLimit); + } const { data: image, error } = await supabase .from("document_images") .select("document_id,storage_path,mime_type,caption,metadata") @@ -41,12 +46,10 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id: if (error) throw new Error(error.message); if (!image) return NextResponse.json({ error: "Image not found." }, { status: 404 }); - const { data: document, error: documentError } = await supabase - .from("documents") - .select("id,metadata") - .eq("id", image.document_id) - .eq("owner_id", user.id) - .maybeSingle(); + const { data: document, error: documentError } = await withOwnerReadScope( + supabase.from("documents").select("id,metadata").eq("id", image.document_id), + access.ownerId, + ).maybeSingle(); if (documentError) throw new Error(documentError.message); if (!document) return NextResponse.json({ error: "Image not found." }, { status: 404 }); diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index a6853c1a9..83ba38e40 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; @@ -12,7 +16,7 @@ import { rowToMedicationRecord, type MedicationRecordRow, } from "@/lib/medication-records"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -56,7 +60,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { const payload = publicMedicationDetailPayload(normalizedSlug); if (!payload) return notFoundResponse(normalizedSlug); return medicationResponse({ @@ -72,7 +76,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Medication requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 7fc2cb999..88aff92a3 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -1,7 +1,11 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { defaultMedicationRecords, ensureMedicationsSeeded } from "@/lib/medication-seed"; @@ -13,7 +17,7 @@ import { type MedicationRecordRow, } from "@/lib/medication-records"; import { medicationToSearchResult, rankMedicationRecords, type MedicationSearchMatch } from "@/lib/medications"; -import { hasPublicApiAuthSignal, publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -76,7 +80,7 @@ export async function GET(request: Request) { }); } - if (!hasPublicApiAuthSignal(request)) { + if (!shouldResolvePublicCatalogAccess(request)) { return medicationResponse({ ...publicMedicationPayload(q, limit), publicAccess: true, @@ -90,7 +94,7 @@ export async function GET(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Medication requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index bfa21c873..79eda031e 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -1,10 +1,14 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -62,6 +66,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s }); } + if (!shouldResolvePublicCatalogAccess(request)) { + const payload = publicRegistryDetailPayload(kind, normalizedSlug); + if (!payload) return notFoundResponse(normalizedSlug); + return registryResponse({ + ...payload, + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); @@ -69,7 +82,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/registry/records/route.ts b/src/app/api/registry/records/route.ts index 5897a1525..373ec658a 100644 --- a/src/app/api/registry/records/route.ts +++ b/src/app/api/registry/records/route.ts @@ -1,10 +1,14 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; -import { publicAccessContext } from "@/lib/public-api-access"; +import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { rankFormRecords, formRecords } from "@/lib/forms"; import { deriveGovernanceColumns, @@ -78,6 +82,13 @@ export async function GET(request: Request) { }); } + if (!shouldResolvePublicCatalogAccess(request)) { + return registryResponse({ + ...publicRegistryPayload(kind, q, limit), + publicAccess: true, + }); + } + const supabase = createAdminClient(); const access = await publicAccessContext(request, supabase); @@ -85,7 +96,7 @@ export async function GET(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "registry", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse("Registry requests are rate limited. Try again shortly.", rateLimit); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index a0474e14d..be077175c 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,11 @@ import { buildSmartRagApiPlan } from "@/lib/smart-rag-api"; import { SOURCE_ONLY_EMBEDDING_SKIP_REASON } from "@/lib/rag-provider"; import { createAdminClient } from "@/lib/supabase/admin"; import * as serverAuth from "@/lib/supabase/auth"; -import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; +import { + allowRateLimitInMemoryFallbackOnUnavailable, + consumeSubjectApiRateLimit, + rateLimitJsonResponse, +} from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; import { clinicalQueryModeSchema, queryClassForClinicalMode, queryForClinicalMode } from "@/lib/clinical-query-mode"; import { parseJsonBody } from "@/lib/validation/body"; @@ -893,7 +897,7 @@ export async function POST(request: Request) { supabase, subject: access.rateLimitSubject, bucket: "search", - allowInMemoryFallbackOnUnavailable: isLocalNoAuthMode(), + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), }); if (rateLimit.limited) { return rateLimitJsonResponse( diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index bf0551c94..ba34855b8 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; import { localProjectRequestIdentityPayload, unsafeLocalProjectResponse } from "@/lib/local-project-guard"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -409,27 +408,11 @@ async function readSetupStatusPayload() { } } -async function requireProductionSetupStatusAuth(request: Request) { +export async function GET(request: Request) { const identity = localProjectRequestIdentityPayload(request); - if (process.env.NODE_ENV !== "production" || identity.localServer.currentUrl) { - return identity; + if (!identity.localServer.safeLocalOrigin) { + return unsafeLocalProjectResponse(identity); } - await requireAuthenticatedUser(request, createAdminClient()); - return identity; -} -export async function GET(request: Request) { - try { - const identity = await requireProductionSetupStatusAuth(request); - if (!identity.localServer.safeLocalOrigin) { - return unsafeLocalProjectResponse(identity); - } - - return setupStatusResponse(await readSetupStatusPayload()); - } catch (error) { - if (error instanceof AuthenticationError) { - return unauthorizedResponse(error); - } - throw error; - } + return setupStatusResponse(await readSetupStatusPayload()); } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 72ddb6023..a2df002f9 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto"; import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { env } from "@/lib/env"; +import { env, publicUploadsEnabled, publicWorkspaceOwnerId } from "@/lib/env"; import { assertAllowedFile, assertFileContentSignature, jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; import { createAdminClient } from "@/lib/supabase/admin"; -import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; +import { publicAccessContext } from "@/lib/public-api-access"; import { probeSupabaseHealth } from "@/lib/supabase/health"; import { optionalFormText, parseFormDataFields } from "@/lib/validation/form-data"; @@ -84,7 +85,11 @@ export async function POST(request: Request) { try { supabase = createAdminClient(); const adminSupabase = supabase; - const user = await requireAuthenticatedUser(request, adminSupabase); + const access = await publicAccessContext(request, adminSupabase); + const uploadOwnerId = access.ownerId ?? (publicUploadsEnabled() ? publicWorkspaceOwnerId() : null); + if (!uploadOwnerId) { + return NextResponse.json({ error: "Public uploads are not configured for this workspace." }, { status: 503 }); + } const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", @@ -107,7 +112,7 @@ export async function POST(request: Request) { const documentId = randomUUID(); const safeName = file.name.replace(/[^\w.\-() ]+/g, "_"); - const storagePath = `${user.id}/documents/${documentId}/${safeName}`; + const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`; const buffer = Buffer.from(await file.arrayBuffer()); // The declared MIME type is client-supplied; verify the real byte signature // before persisting a clinical document. @@ -117,7 +122,7 @@ export async function POST(request: Request) { const { data: duplicate, error: duplicateError } = await adminSupabase .from("documents") .select("id,title,file_name,status,page_count,chunk_count,image_count,created_at") - .eq("owner_id", user.id) + .eq("owner_id", uploadOwnerId) .eq("content_hash", contentHash) .maybeSingle(); @@ -147,7 +152,7 @@ export async function POST(request: Request) { }; const namePlan = await planDocumentName({ supabase: namingSupabase, - ownerId: user.id, + ownerId: uploadOwnerId, fileName: file.name, requestedTitle: uploadMetadata.title, contentHash, @@ -160,7 +165,7 @@ export async function POST(request: Request) { .from("documents") .insert({ id: documentId, - owner_id: user.id, + owner_id: uploadOwnerId, title, description, file_name: file.name, @@ -178,7 +183,7 @@ export async function POST(request: Request) { review_date: null, uploaded_at: uploadedAt, indexed_at: null, - uploaded_by: user.id, + uploaded_by: uploadOwnerId, original_file_name: namePlan.originalFileName, original_title: namePlan.originalTitle, smart_title_base: namePlan.baseTitle, @@ -202,7 +207,7 @@ export async function POST(request: Request) { insertedDocumentOwnerId = null; return duplicateUploadResponse({ supabase, - ownerId: user.id, + ownerId: uploadOwnerId, contentHash, storagePath: uploadedPath, }); @@ -210,7 +215,7 @@ export async function POST(request: Request) { throw new Error(documentError.message); } insertedDocumentId = documentId; - insertedDocumentOwnerId = user.id; + insertedDocumentOwnerId = uploadOwnerId; const { data: job, error: jobError } = await supabase .from("ingestion_jobs") @@ -230,7 +235,7 @@ export async function POST(request: Request) { .from("documents") .delete() .eq("id", documentId) - .eq("owner_id", user.id); + .eq("owner_id", uploadOwnerId); if (rollbackDocumentError) { throw new Error( `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, @@ -242,7 +247,7 @@ export async function POST(request: Request) { } await writeAuditLog(supabase, { - ownerId: user.id, + ownerId: uploadOwnerId, action: "document_upload", resourceType: "document", resourceId: documentId, diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..018ec0c1d 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -45,69 +40,33 @@ import { Wrench, X, } from "lucide-react"; -import { - type CSSProperties, - type FormEvent, - type RefObject, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type CSSProperties, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; +import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env"; import { appBackdrop, answerSurface, - chatMicroAction, - clinicalDivider, cn, - EmptyState, - fieldControlPlain, fieldControlWithIcon, fieldIcon, floatingControl, - iconTilePremium, - metadataPill, - panelSubtle, primaryControl, - SourceProvenance, - SourceStatusBadge, - sourceCard, - subtleStatusPill, - tableCard, - tableCardHeader, - tableMicroActionRow, textMuted, - toneDanger, - toneInfo, - toneNeutral, toneSuccess, toneWarning, } from "@/components/ui-primitives"; import { useAuthSession } from "@/lib/supabase/client"; -import { SafeBoldText } from "@/components/SafeBoldText"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; -import { StatusBadge } from "@/components/clinical-dashboard/badges"; import { type SidebarIdentity, deriveSidebarIdentity, @@ -126,45 +85,18 @@ import { type SetupCheck, type IngestionQualityReviewItem, } from "@/components/clinical-dashboard/DocumentManagerPanel"; -import { - GuideDialog, - GuideTrigger, - SectionHeading, - UtilityDrawer, -} from "@/components/clinical-dashboard/dashboard-shell"; -import { - cleanDisplayTitle, - sanitizeAnswerDisplayText, - sanitizeDisplayText, -} from "@/components/clinical-dashboard/display-text"; +import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; +import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +130,7 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,21 +168,13 @@ import { maxStoredAnswerTurns, savePersistedAnswerThread, } from "@/lib/answer-thread-storage"; -import { buildAnswerRenderModel, type AnswerRenderModel } from "@/lib/answer-render-policy"; -import { sourceTextForCompactDisplay } from "@/lib/source-text-sanitizer"; +import { buildAnswerRenderModel } from "@/lib/answer-render-policy"; import { frontendSourceGovernanceWarnings, groupSourceGovernanceWarnings, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; -import { - tagSearchText, - type SmartDocumentTag, - type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, -} from "@/lib/document-tags"; +import { type SmartDocumentTag, type SmartDocumentTagFacet } from "@/lib/document-tags"; import type { ClinicalDocument, DocumentMatch, @@ -263,19 +187,12 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; -import { - createQuoteFollowUp, - type AnswerEvidenceMapRow, - type AnswerViewMode, - shouldPollForUpdates, -} from "@/lib/ward-output"; +import { createQuoteFollowUp, type AnswerViewMode, shouldPollForUpdates } from "@/lib/ward-output"; export const navigationHashes = ["#search", "#quotes", "#images", "#sources"] as const; export const mobileSectionFabMediaQuery = @@ -487,367 +404,6 @@ async function readAnswerStream(response: Response, onProgress: (message: string function normalizeNavigationHash(hash: string) { return navigationHashes.includes(hash as (typeof navigationHashes)[number]) ? hash : "#search"; } - -function compactClinicalTableCaption(item: VisualEvidenceCard) { - const raw = item.tableTitle || item.tableLabel || item.caption || "Clinical table"; - const cleaned = sourceTextForCompactDisplay(raw) - .replace(/\btable\s+\d+\s*[:.-]?\s*/i, "") - .replace(/\b(?:page|p\.)\s*\d+\b/gi, "") - .replace(/\s{2,}/g, " ") - .trim(); - const caption = cleaned || "Clinical table"; - return caption.length <= 72 ? caption : `${caption.slice(0, 69).trim()}...`; -} - -function visualEvidenceHeader(item: VisualEvidenceCard) { - const titleSource = [item.tableLabel, item.tableTitle].filter(Boolean).join(" · "); - const titleText = sourceTextForCompactDisplay(titleSource).trim(); - const captionText = sourceTextForCompactDisplay(item.caption ?? "").trim(); - const normalizedTitle = titleText.toLowerCase(); - const normalizedCaption = captionText.toLowerCase(); - const isDuplicateCaption = - Boolean(normalizedCaption) && - (normalizedCaption.startsWith(normalizedTitle) || normalizedCaption === normalizedTitle); - return { - title: titleText || captionText || "Visual evidence", - caption: isDuplicateCaption ? null : captionText, - }; -} - -function VisualEvidenceStrip({ - evidence, - collapsed = false, - embedded = false, -}: { - evidence: VisualEvidenceCard[]; - collapsed?: boolean; - embedded?: boolean; -}) { - function looksLikeTableText(value?: string | null) { - return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); - } - - if (collapsed) { - return ( -
- - - -
- ); - } - - const content = ( - <> - - {evidence.length === 0 ? ( - - ) : ( -
- {evidence.map((item) => { - const tableMarkdown = item.accessibleTableMarkdown?.trim() - ? item.accessibleTableMarkdown - : looksLikeTableText(item.tableTextSnippet) - ? item.tableTextSnippet - : null; - const hasStructuredTable = Boolean(tableMarkdown || item.tableRows?.length || item.tableColumns?.length); - const tableCaption = compactClinicalTableCaption(item); - const sourceHeader = visualEvidenceHeader(item); - const displayLabels = smartEvidenceTags( - item.labels, - [[item.tableLabel, item.tableTitle].filter(Boolean).join(": "), item.caption, item.tableTextSnippet] - .filter(Boolean) - .join(" "), - ); - return ( -
-
- -
-
- {!hasStructuredTable ?

{sourceHeader.title}

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

{sourceHeader.caption}

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

- {sourceTextForCompactDisplay(item.tableTextSnippet)} -

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

Claims checked

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

- {directCount} direct · {partialCount} partial -

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

Gap {index + 1}

-

{warning}

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

- {compactClinicalTableCaption(item)} -

-

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

-
- - - -
- ))} -
- ) : ( - - ); - } - - if (tab === "Images") { - return visualEvidence.length ? ( - - ) : ( - - ); - } - - if (tab === "Quotes") { - return ( - - ); - } - - return ; -} - /** * A completed Q&A exchange kept on screen after a newer answer arrives, so * Answer mode reads as a conversation thread instead of replacing each result. @@ -2174,7 +1730,10 @@ export function ClinicalDashboard({ process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks); const canUsePrivateApis = localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated"); - const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis; + const canUploadDocuments = canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis); + const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady; + const canRunSearch = + explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch; const closeDashboardTransientSurfaces = useCallback( (except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => { if (except !== "guide") setGuideOpen(false); @@ -2355,20 +1914,25 @@ export function ClinicalDashboard({ const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null); if (!setupResponse) { - setApiUnavailable(true); - setSetupWarning("The local API is unavailable."); - return; - } - - if (setupResponse.ok) { + if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); + } else { + setApiUnavailable(true); + setSetupWarning("The local API is unavailable."); + return; + } + } else if (setupResponse.ok) { const payload = (await setupResponse.json()) as SetupStatusPayload; setSetupChecks(payload.checks ?? fallbackSetupChecks); nextDemoMode = Boolean(payload.demoMode); routeIndexingActive = Boolean(payload.indexingActive); routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs); if (nextDemoMode) setDemoMode(true); + } else if (isDeployedClinicalKb()) { + setSetupWarning("Setup status could not be loaded. You can still try search."); } else { setApiUnavailable(true); + return; } } @@ -2922,11 +2486,13 @@ export function ClinicalDashboard({ function searchNetworkFailure(label: string) { const offline = typeof navigator !== "undefined" && !navigator.onLine; - const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server"; + const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB"; return makeSearchError( offline ? `${label} could not run because the browser is offline.` - : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, + : isDeployedClinicalKb() + ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.` + : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`, undefined, true, ); @@ -4005,21 +3571,21 @@ export function ClinicalDashboard({

); - const showAuthPanel = !clientDemoMode && !canUsePrivateApis; - const showDegradedNotice = !isOnline || apiUnavailable; + const showAuthPanel = false; + const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); const hasMobileBottomSearch = searchMode !== "answer"; const showDesktopHomeComposer = - !loading && !error && - ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || - (searchMode === "documents" && - activeModeResultKind === "documents" && - documentMatches.length === 0 && - !modeSearchSubmitted) || - (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || - (activeModeResultKind === "differentials" && !modeSearchSubmitted) || + (activeModeResultKind === "tools" || activeModeResultKind === "favourites" || - activeModeResultKind === "tools"); + (!loading && + ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) || + (searchMode === "documents" && + activeModeResultKind === "documents" && + documentMatches.length === 0 && + !modeSearchSubmitted) || + (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || + (activeModeResultKind === "differentials" && !modeSearchSubmitted)))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. @@ -4044,14 +3610,18 @@ export function ClinicalDashboard({ summary={ !isOnline ? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access." - : "The local API did not respond. Check the app server and setup status before retrying." + : isDeployedClinicalKb() + ? "The app could not reach its API. Try again in a moment." + : "The local API did not respond. Check the app server and setup status before retrying." } mobileSummary={!isOnline ? "Offline" : "API unavailable"} >

{!isOnline ? "Reconnect before uploading documents, refreshing source URLs, or generating answers." - : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."} + : isDeployedClinicalKb() + ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly." + : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}

); @@ -4079,7 +3649,7 @@ export function ClinicalDashboard({ { id: "upload", label: "Upload", - summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready", + summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready", panelId: "dashboard-upload-section", icon: UploadCloud, }, @@ -4659,7 +4229,7 @@ export function ClinicalDashboard({ diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index bd4c4d222..630790c43 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1914,7 +1914,6 @@ export function DocumentViewer({ const [documentSearchError, setDocumentSearchError] = useState(null); const [reviewingTableFactId, setReviewingTableFactId] = useState(null); const [isOnline, setIsOnline] = useState(true); - const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [mobileActionsOpen, setMobileActionsOpen] = useState(false); const [useNativePdfViewer, setUseNativePdfViewer] = useState(() => getInitialPdfViewerMode().useNativePdfViewer); @@ -1924,11 +1923,22 @@ export function DocumentViewer({ const [viewerModeInitialized] = useState(true); const generatedSummaryRef = useRef(null); const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession(); + const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false); const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true"); const localNoAuthMode = isLocalNoAuthMode(); const clientDemoMode = localNoAuthMode || serverDemoMode; + const canViewSourceDocuments = localProjectReady; const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated"); + useEffect(() => { + if (authStatus !== "loading") { + const resetId = window.setTimeout(() => setAuthLoadingTimedOut(false), 0); + return () => window.clearTimeout(resetId); + } + const timeoutId = window.setTimeout(() => setAuthLoadingTimedOut(true), 4_000); + return () => window.clearTimeout(timeoutId); + }, [authStatus]); + useEffect(() => { if (typeof window === "undefined" || !viewerModeInitialized || hasExplicitPdfViewerMode) return; @@ -2002,10 +2012,10 @@ export function DocumentViewer({ }, [isConfigured]); useEffect(() => { - if (!canUsePrivateApis && authStatus === "loading") { + if (!canViewSourceDocuments && authStatus === "loading") { return () => undefined; } - if (!canUsePrivateApis) { + if (!canViewSourceDocuments) { return () => undefined; } @@ -2090,9 +2100,17 @@ export function DocumentViewer({ setTableFacts([]); setChunks([]); setIndexHealth(null); - setViewerError( - detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.", - ); + const message = + detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded."; + if (!canUsePrivateApis && !clientDemoMode && message === "Document not found.") { + setViewerError( + isConfigured + ? "Sign in to open private source documents." + : "Supabase browser authentication is not configured for private source documents.", + ); + } else { + setViewerError(message); + } } if (signedUrlResult.status === "fulfilled") { @@ -2141,7 +2159,7 @@ export function DocumentViewer({ }, [ authStatus, authorizationHeader, - canUsePrivateApis, + canViewSourceDocuments, clientDemoMode, documentId, chunkId, @@ -2153,7 +2171,7 @@ export function DocumentViewer({ useEffect(() => { const query = sourceSearch.trim(); - if (!canUsePrivateApis || query.length < 2) { + if (!canViewSourceDocuments || query.length < 2) { const reset = window.setTimeout(() => { setDocumentSearchResults([]); setSearchingDocument(false); @@ -2195,7 +2213,7 @@ export function DocumentViewer({ window.clearTimeout(timeout); controller.abort(); }; - }, [authorizationHeader, canUsePrivateApis, clientDemoMode, documentId, markSessionExpired, sourceSearch]); + }, [authorizationHeader, canViewSourceDocuments, clientDemoMode, documentId, markSessionExpired, sourceSearch]); useEffect(() => { const updateOnline = () => setIsOnline(navigator.onLine); @@ -2208,15 +2226,6 @@ export function DocumentViewer({ }; }, []); - useEffect(() => { - if (canUsePrivateApis || authStatus !== "loading") { - return () => undefined; - } - - const timeout = window.setTimeout(() => setAuthLoadingTimedOut(true), 3000); - return () => window.clearTimeout(timeout); - }, [authStatus, canUsePrivateApis]); - async function summarize() { if (!canSummarizeDocument) { setSummaryError("Load a source document before summarising."); @@ -2248,14 +2257,17 @@ export function DocumentViewer({ } const authViewerError = - !canUsePrivateApis && (authStatus !== "loading" || authLoadingTimedOut) - ? isConfigured - ? "Sign in to open private source documents." - : "Supabase browser authentication is not configured for private source documents." + !canUsePrivateApis && + !clientDemoMode && + !loadingDocument && + !document && + (authStatus !== "loading" || authLoadingTimedOut) && + (viewerError === "Sign in to open private source documents." || + viewerError === "Supabase browser authentication is not configured for private source documents.") + ? viewerError : null; - const effectiveLoadingDocument = !canUsePrivateApis - ? authStatus === "loading" && !authLoadingTimedOut && loadingDocument - : loadingDocument; + const effectiveLoadingDocument = + !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument ? true : loadingDocument; const effectiveViewerError = authViewerError ?? viewerError; const viewerState = effectiveLoadingDocument ? "loading" diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx index b47b1267a..a362bc811 100644 --- a/src/components/applications-launcher-page.tsx +++ b/src/components/applications-launcher-page.tsx @@ -906,12 +906,17 @@ export function ApplicationsLauncherWorkspace({ }: ApplicationsLauncherWorkspaceProps) { const [uncontrolledQuery, setUncontrolledQuery] = useState(""); const [activeFilter, setActiveFilter] = useState("all"); - const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery)); const isDashboardTools = variant === "dashboard-tools"; const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true); const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy; const query = controlledQuery ?? uncontrolledQuery; const normalizedQuery = query.trim().toLowerCase(); + const queryDerivedId = useMemo(() => initialToolId(query), [query]); + const [selection, setSelection] = useState(() => ({ + queryKey: (controlledQuery ?? "").trim().toLowerCase(), + id: initialToolId(controlledQuery), + })); + const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId; const filteredApps = useMemo(() => { return launcherApps.filter((app) => { @@ -941,7 +946,7 @@ export function ApplicationsLauncherWorkspace({ } function openTool(id: string) { - setSelectedId(id); + setSelection({ queryKey: normalizedQuery, id }); setDetailOpen(true); } diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index b25fae026..381f0da09 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -188,7 +188,9 @@ export function UploadPanel({ return; } if (!canUpload) { - changeStatus("Sign in before uploading private guideline files."); + changeStatus( + demoMode ? demoUploadReadOnlyMessage : "Uploads are unavailable until this public workspace is configured.", + ); return; } @@ -202,7 +204,7 @@ export function UploadPanel({ setUploading(true); changeStatus( files.length === 1 - ? "Uploading private document to Supabase Storage..." + ? "Uploading document to Supabase Storage..." : `Uploading 1 of ${files.length}: ${files[0].name}`, ); @@ -226,8 +228,8 @@ export function UploadPanel({ if (failures.length === 0) { changeStatus( files.length === 1 - ? "Successfully uploaded private document to storage queue." - : `Successfully uploaded ${files.length} private documents.`, + ? "Successfully uploaded document to storage queue." + : `Successfully uploaded ${files.length} documents.`, ); if (input) input.value = ""; onUploaded(); diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index 5efffa0ad..8398bc679 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -24,6 +24,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { ModeHomeTemplate } from "@/components/mode-home-template"; import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band"; import { SafeBoldText } from "@/components/SafeBoldText"; @@ -726,7 +727,12 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus; status === "loading" ? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` } : status === "unauthorized" - ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Sign in to search your ${noun} registry.` } + ? { + Icon: Shield, + spin: false, + tone: "warning" as const, + text: `Your session expired. Sign in again to search your private ${noun} registry.`, + } : { Icon: ShieldAlert, spin: false, @@ -834,9 +840,11 @@ export function DocumentSearchResultsPanel({ } const unavailableMessage = apiUnavailable - ? "The local API is unavailable. Check the app server before searching documents." + ? isDeployedClinicalKb() + ? "Clinical KB could not be reached. Check your connection and try again shortly." + : "The local API is unavailable. Check the app server before searching documents." : authUnavailable - ? "Sign in or enable local no-auth mode before listing private indexed documents." + ? "Your session expired. Sign in again to view private indexed documents." : !realDataReady ? setupWarning || "Complete the search setup before using Documents mode." : null; diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index c1b2835a3..68072f029 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -22,7 +22,6 @@ import { favouriteItems, favouriteSets, favouriteTabs, - favouriteTypeCount, type FavouriteItem, type FavouriteSet, type FavouriteTabId, diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 05596db09..597d4d459 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -31,6 +31,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search- import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context"; import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { medicationMatchesCommandScopes } from "@/lib/search-command-surface"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives"; type MedicationPrescribingWorkspaceProps = { @@ -414,9 +415,13 @@ function StatusNotice({ }: Pick) { if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null; const message = authUnavailable - ? "Private medication search is waiting for sign-in." + ? isDeployedClinicalKb() + ? "Sign in to search your private medication library." + : "Private medication search is waiting for sign-in." : apiUnavailable - ? "Medication search is using the local mockup while the API is unavailable." + ? isDeployedClinicalKb() + ? "Medication search is temporarily unavailable. Try again shortly." + : "Medication search is using the local mockup while the API is unavailable." : setupWarning || "Medication search setup is still warming up."; return ( diff --git a/src/components/clinical-dashboard/visual-evidence.tsx b/src/components/clinical-dashboard/visual-evidence.tsx index 2f84e2134..5dcca9e54 100644 --- a/src/components/clinical-dashboard/visual-evidence.tsx +++ b/src/components/clinical-dashboard/visual-evidence.tsx @@ -29,7 +29,6 @@ import { evidenceTabOrder, QuoteCards, simpleClinicalTableProps, - VerificationWorkspace, } from "@/components/clinical-dashboard/evidence-panels"; import { QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; import { diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx index eb3e040de..03a6495c1 100644 --- a/src/components/forms/forms-home-page.tsx +++ b/src/components/forms/forms-home-page.tsx @@ -90,10 +90,10 @@ export function FormsHomePage() { ) : registry.status === "unauthorized" ? ( ) : registry.status === "error" ? ( } - title="Sign in required" - body={`Sign in to view this ${copy.noun}. Registry records are private to your workspace.`} - action={{ href: "/", label: "Go to sign in" }} + title="Session expired" + body={`Your session expired. Sign in again to view your private ${copy.noun}. Public ${copy.noun} records remain available from search.`} + action={{ href: "/", label: "Open account setup" }} /> ); } diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 132432f57..6624fc2e6 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -92,10 +92,10 @@ export function ServicesHomePage() { ) : registry.status === "unauthorized" ? ( ) : registry.status === "error" ? ( ({ urlQuery, value: initialQuery })); const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; const registry = useRegistryRecords("service"); - const searchableRecords = - registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords; + const searchableRecords = useMemo( + () => (registry.status === "ready" ? registry.records : registry.status === "loading" ? [] : serviceRecords), + [registry.records, registry.status], + ); const matches = useMemo(() => { const ranked = rankServiceRecords(searchableRecords, query); return ranked.length ? ranked.map((match) => match.service) : query.trim() ? [] : searchableRecords; diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 2a19a21ba..842d99dc8 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -1,10 +1,16 @@ import { NextResponse } from "next/server"; +import { isLocalNoAuthMode } from "@/lib/env"; import { PublicApiError } from "@/lib/http"; import type { RateLimitSubject } from "@/lib/public-api-access"; import type { createAdminClient } from "@/lib/supabase/admin"; +/** Prefer durable RPC rate limits; fall back to per-instance memory when the DB function is unavailable. */ +export function allowRateLimitInMemoryFallbackOnUnavailable() { + return isLocalNoAuthMode() || process.env.NODE_ENV === "production"; +} + export type ApiRateLimitBucket = - "answer" | "search" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; + "answer" | "search" | "document_read" | "document_summarize" | "document_reindex" | "bulk_reindex" | "registry"; export type ApiRateLimitResult = { limited: boolean; @@ -17,6 +23,7 @@ export type ApiRateLimitResult = { const apiRateLimitDefaults = { answer: { limit: 30, windowSeconds: 60 }, search: { limit: 240, windowSeconds: 60 }, + document_read: { limit: 180, windowSeconds: 60 }, document_summarize: { limit: 12, windowSeconds: 60 }, document_reindex: { limit: 6, windowSeconds: 60 }, bulk_reindex: { limit: 2, windowSeconds: 60 }, @@ -26,6 +33,7 @@ const apiRateLimitDefaults = { const anonymousApiRateLimitDefaults: Partial> = { answer: { limit: 6, windowSeconds: 60 }, search: { limit: 60, windowSeconds: 60 }, + document_read: { limit: 45, windowSeconds: 60 }, }; type SupabaseAdmin = ReturnType; diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts new file mode 100644 index 000000000..82bb8f3a7 --- /dev/null +++ b/src/lib/deployed-app.ts @@ -0,0 +1,3 @@ +export function isDeployedClinicalKb() { + return process.env.NODE_ENV === "production"; +} diff --git a/src/lib/env.ts b/src/lib/env.ts index ac5d8dbf5..f6acba54f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -11,6 +11,8 @@ const envSchema = z.object({ LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"), LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), LOCAL_NO_AUTH_OWNER_ID: z.string().optional(), + PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(), + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(), OPENAI_API_KEY: z.string().optional(), OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"), // Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding @@ -192,3 +194,11 @@ export function isLocalNoAuthMode() { return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth); } + +export function publicWorkspaceOwnerId() { + return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null; +} + +export function publicUploadsEnabled() { + return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true"; +} diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 26b80f3fc..4de54ea73 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -1,5 +1,10 @@ import { createHash } from "node:crypto"; import type { createAdminClient } from "@/lib/supabase/admin"; +import { + consumeSubjectApiRateLimit, + allowRateLimitInMemoryFallbackOnUnavailable, + type ApiRateLimitResult, +} from "@/lib/api-rate-limit"; import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth"; type AdminClient = ReturnType; @@ -25,14 +30,38 @@ export function anonymousApiSubjectKey(request: Request) { return `anon:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`; } -export function hasPublicApiAuthSignal(request: Request) { - const authorization = request.headers.get("authorization") ?? ""; - if (/^Bearer\s+\S+/i.test(authorization)) return true; - +export function hasSessionCookieSignal(request: Request) { const cookieHeader = request.headers.get("cookie") ?? ""; return cookieHeader.includes("sb-"); } +export function hasBearerAuthAttempt(request: Request) { + const authorization = request.headers.get("authorization") ?? ""; + return /^Bearer\s+\S+/i.test(authorization); +} + +/** True when the request may carry a durable Supabase session (cookie), not a bare bearer attempt. */ +export function hasPublicApiAuthSignal(request: Request) { + return hasSessionCookieSignal(request); +} + +/** Anonymous callers with no cookie or bearer skip auth resolution and rate limits on curated public catalogs. */ +export function shouldResolvePublicCatalogAccess(request: Request) { + return hasSessionCookieSignal(request) || hasBearerAuthAttempt(request); +} + +type OwnerScopedQuery = { + eq(column: string, value: unknown): T; + is(column: string, value: null): T; + or(filters: string): T; +}; + +/** Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. */ +export function withOwnerReadScope>(query: T, ownerId: string | undefined): T { + if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`); + return query.is("owner_id", null); +} + export async function publicAccessContext(request: Request, supabase: AdminClient) { const user = await getOptionalAuthenticatedUser(request, supabase); if (user) { @@ -49,3 +78,17 @@ export async function publicAccessContext(request: Request, supabase: AdminClien rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject, }; } + +export async function enforceDocumentReadRateLimit( + request: Request, + supabase: AdminClient, +): Promise<{ access: Awaited>; rateLimit: ApiRateLimitResult }> { + const access = await publicAccessContext(request, supabase); + const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "document_read", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + return { access, rateLimit }; +} diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 6adea390f..e43a125e6 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1456,6 +1456,7 @@ export function retrievalPlanCacheQuery( `mode:${modeKey(args)}`, `topK:${args.topK ?? 8}`, `min:${args.minSimilarity ?? 0.15}`, + `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`, `rag:${ragDeepMemoryVersion}`, `force:${args.forceEmbedding ? 1 : 0}`, ].join("|"); @@ -1554,7 +1555,7 @@ type SharedCacheMissReason = function sharedCacheSelector( supabase: ReturnType, kind: SharedCacheKind, - args: Pick, + args: Pick, indexingVersion: string, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), ) { @@ -1719,7 +1720,10 @@ async function getSharedCachedSearch( } async function getSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, startedAt: number, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null; @@ -1752,7 +1756,7 @@ async function getSharedCachedAnswer( async function replaceSharedCacheRow( kind: SharedCacheKind, - args: Pick, + args: Pick, payload: unknown, ttlMs: number, normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)), @@ -1804,7 +1808,10 @@ function setSharedCachedSearch( } function setSharedCachedAnswer( - args: Pick, + args: Pick< + SearchChunksArgs, + "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding" + >, answer: RagAnswer, ) { if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return; @@ -5386,6 +5393,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { // 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(); + if (args.forceEmbedding && sourceOnlyRetrieval) { + throw new Error("forceEmbedding requires embedding-capable retrieval; source-only mode cannot exercise vectors."); + } // A3: shared across every withMemoryBoostedCandidates call in this request so the same // owner/query memory cards are fetched at most once per (query, embedding-present, count). const memoryCardCache: MemoryCardCache = new Map(); @@ -5463,7 +5473,10 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.shared_cache_miss_reason = sharedCached.reason; } - if (shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) { + if ( + !args.forceEmbedding && + shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions) + ) { // Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look // unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the // query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval @@ -5722,7 +5735,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry, }); const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass); - applyCoverageGateTelemetry(telemetry, coverageGate, coverageGate.accepted); + applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted); if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); @@ -5752,7 +5765,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { } catch (error) { // In auto mode a failed embedding call (e.g. quota exhausted) degrades to the lexical // results already gathered rather than failing the whole search. "openai" mode rethrows. - if (!allowsAutoDegrade()) throw error; + if (args.forceEmbedding || !allowsAutoDegrade()) throw error; telemetry.embedding_skipped = true; telemetry.embedding_skip_reason = sourceOnlyReason(error); telemetry.vector_skipped_reason = classifyProviderFailure(error); @@ -5854,10 +5867,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { latencyMs: hybridResult.latencyMs, topScore: layerTopScore((hybridData ?? []) as SearchResult[]), }); + const vectorCandidates = mergeSearchResults( + mergeSearchResults((hybridData ?? []) as SearchResult[], embeddingFieldCandidates), + indexUnitCandidates, + ); if (!hybridError) { const rerankStartedAt = Date.now(); - const merged = mergeSearchResults((hybridData ?? []) as SearchResult[], textFastResults); + const merged = args.forceEmbedding ? vectorCandidates : mergeSearchResults(vectorCandidates, textFastResults); const mergedWithMetadata = await attachDocumentRankingMetadata(supabase, merged, args.ownerId); const memoryBoost = await withMemoryBoostedCandidates({ supabase, @@ -5918,7 +5935,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { return (data ?? []) as SearchResult[]; }), ).catch((error) => { - if (textFastResults.length > 0) return [] as SearchResult[][]; + if (!args.forceEmbedding && textFastResults.length > 0) return [] as SearchResult[][]; throw error; }); const fallbackLatencyMs = Date.now() - fallbackRpcStartedAt; @@ -5930,9 +5947,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { }); const rerankStartedAt = Date.now(); + const fallbackVectorCandidates = mergeSearchResults( + mergeSearchResults(resultSets.flat(), embeddingFieldCandidates), + indexUnitCandidates, + ); const mergedWithMetadata = await attachDocumentRankingMetadata( supabase, - mergeSearchResults(resultSets.flat(), textFastResults), + args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults), args.ownerId, ); const memoryBoost = await withMemoryBoostedCandidates({ diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts index db67f2cbe..8f20205c9 100644 --- a/src/lib/supabase/auth.ts +++ b/src/lib/supabase/auth.ts @@ -31,12 +31,14 @@ function readCookies(cookieHeader: string | null): Map { return cookies; } -function extractSessionAccessToken(request: Request): string | null { +function extractBearerAccessToken(request: Request): string | null { const authorization = request.headers.get("authorization") ?? ""; const match = authorization.match(/^Bearer\s+(.+)$/i); const headerToken = match?.[1]?.trim(); - if (headerToken) return headerToken; + return headerToken || null; +} +function extractCookieSessionAccessToken(request: Request): string | null { const cookies = readCookies(request.headers.get("cookie")); const legacyAccessToken = cookies.get("sb-access-token")?.trim(); if (legacyAccessToken) return legacyAccessToken; @@ -57,6 +59,16 @@ function extractSessionAccessToken(request: Request): string | null { return null; } +function extractSessionAccessToken(request: Request): string | null { + return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request); +} + +async function getUserFromAccessToken(supabase: AdminClient, token: string): Promise { + const { data, error } = await supabase.auth.getUser(token); + if (error || !data.user?.id) return null; + return { id: data.user.id }; +} + export class AuthenticationError extends Error { constructor(message = "Authentication required.") { super(message); @@ -72,7 +84,7 @@ export function unauthorizedResponse(error?: AuthenticationError) { /** * Resolve the user from the `@supabase/ssr` cookie session. The * `sb--auth-token` cookie it writes is base64-encoded (and chunked when - * large), which `extractSessionAccessToken`'s plain-JSON parser cannot read, so + * large), which `extractCookieSessionAccessToken`'s plain-JSON parser cannot read, so * this uses the ssr server client to decode + validate it. Returns null when * the public env is absent or no `sb-` cookie is present. */ @@ -102,22 +114,28 @@ async function getUserFromRequestCookies(request: Request): Promise { - // 1. Bearer token / legacy cookie (programmatic callers + current clients). - const token = extractSessionAccessToken(request); - if (token) { - const { data, error } = await supabase.auth.getUser(token); - if (!error && data.user?.id) { - return { id: data.user.id }; - } +async function resolveOptionalAuthenticatedUser( + request: Request, + supabase: AdminClient, +): Promise { + const bearerToken = extractBearerAccessToken(request); + if (bearerToken) { + const bearerUser = await getUserFromAccessToken(supabase, bearerToken); + if (bearerUser) return bearerUser; } - // 2. @supabase/ssr cookie session (persistent cookie logins). - const cookieUser = await getUserFromRequestCookies(request); - if (cookieUser) { - return cookieUser; + const cookieToken = extractCookieSessionAccessToken(request); + if (cookieToken && cookieToken !== bearerToken) { + const cookieTokenUser = await getUserFromAccessToken(supabase, cookieToken); + if (cookieTokenUser) return cookieTokenUser; } + return getUserFromRequestCookies(request); +} + +export async function requireAuthenticatedUser(request: Request, supabase: AdminClient): Promise { + const user = await resolveOptionalAuthenticatedUser(request, supabase); + if (user) return user; throw new AuthenticationError(); } @@ -125,14 +143,8 @@ export async function getOptionalAuthenticatedUser( request: Request, supabase: AdminClient, ): Promise { - const token = extractSessionAccessToken(request); - if (token) { - const { data, error } = await supabase.auth.getUser(token); - if (error || !data.user?.id) { - throw new AuthenticationError(); - } - return { id: data.user.id }; - } - - return getUserFromRequestCookies(request); + return resolveOptionalAuthenticatedUser(request, supabase); } + +// Retained for callers that only need a single token string. +export { extractSessionAccessToken }; diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 00ecad79b..55db0bd38 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2,2632 +2,2622 @@ across many generated table and RPC shapes, so keep Json broad until the call sites are narrowed coherently. */ /* eslint-disable @typescript-eslint/no-explicit-any */ -export type Json = any +export type Json = any; -export type Vector = number[] | string +export type Vector = number[] | string; export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: "14.5" - } + PostgrestVersion: "14.5"; + }; graphql_public: { Tables: { - [_ in never]: never - } + [_ in never]: never; + }; Views: { - [_ in never]: never - } + [_ in never]: never; + }; Functions: { graphql: { Args: { - extensions?: Json - operationName?: string - query?: string - variables?: Json - } - Returns: Json - } - } + extensions?: Json; + operationName?: string; + query?: string; + variables?: Json; + }; + Returns: Json; + }; + }; Enums: { - [_ in never]: never - } + [_ in never]: never; + }; CompositeTypes: { - [_ in never]: never - } - } + [_ in never]: never; + }; + }; public: { Tables: { api_rate_limits: { Row: { - bucket: string - owner_id: string - request_count: number - updated_at: string - window_start: string - } + bucket: string; + owner_id: string; + request_count: number; + updated_at: string; + window_start: string; + }; Insert: { - bucket: string - owner_id: string - request_count?: number - updated_at?: string - window_start?: string - } + bucket: string; + owner_id: string; + request_count?: number; + updated_at?: string; + window_start?: string; + }; Update: { - bucket?: string - owner_id?: string - request_count?: number - updated_at?: string - window_start?: string - } - Relationships: [] - } + bucket?: string; + owner_id?: string; + request_count?: number; + updated_at?: string; + window_start?: string; + }; + Relationships: []; + }; audit_logs: { Row: { - action: string - created_at: string - id: string - metadata: Json - owner_id: string | null - resource_id: string | null - resource_type: string | null - } + action: string; + created_at: string; + id: string; + metadata: Json; + owner_id: string | null; + resource_id: string | null; + resource_type: string | null; + }; Insert: { - action: string - created_at?: string - id?: string - metadata?: Json - owner_id?: string | null - resource_id?: string | null - resource_type?: string | null - } + action: string; + created_at?: string; + id?: string; + metadata?: Json; + owner_id?: string | null; + resource_id?: string | null; + resource_type?: string | null; + }; Update: { - action?: string - created_at?: string - id?: string - metadata?: Json - owner_id?: string | null - resource_id?: string | null - resource_type?: string | null - } - Relationships: [] - } + action?: string; + created_at?: string; + id?: string; + metadata?: Json; + owner_id?: string | null; + resource_id?: string | null; + resource_type?: string | null; + }; + Relationships: []; + }; clinical_registry_record_sources: { Row: { - created_at: string - document_id: string - id: string - note: string | null - owner_id: string - record_id: string - } + created_at: string; + document_id: string; + id: string; + note: string | null; + owner_id: string; + record_id: string; + }; Insert: { - created_at?: string - document_id: string - id?: string - note?: string | null - owner_id: string - record_id: string - } + created_at?: string; + document_id: string; + id?: string; + note?: string | null; + owner_id: string; + record_id: string; + }; Update: { - created_at?: string - document_id?: string - id?: string - note?: string | null - owner_id?: string - record_id?: string - } - Relationships: [] - } + created_at?: string; + document_id?: string; + id?: string; + note?: string | null; + owner_id?: string; + record_id?: string; + }; + Relationships: []; + }; clinical_registry_records: { Row: { - best_use: string | null - catalog_payload: Json - catalogue_label: string | null - catchments: string[] - contacts: Json - cost: string | null - created_at: string - criteria: Json - eligibility: string | null - id: string - kind: string - last_reviewed_at: string | null - location: string | null - navigator_query: string | null - owner_id: string - primary_contact: Json | null - referral: string | null - referral_info: Json - review_due_at: string | null - route: string | null - slug: string - source: Json - source_status: string - status_chips: Json - subtitle: string | null - summary_cards: Json - tags: string[] - title: string - updated_at: string - validation_status: string - verification: Json - } + best_use: string | null; + catalog_payload: Json; + catalogue_label: string | null; + catchments: string[]; + contacts: Json; + cost: string | null; + created_at: string; + criteria: Json; + eligibility: string | null; + id: string; + kind: string; + last_reviewed_at: string | null; + location: string | null; + navigator_query: string | null; + owner_id: string; + primary_contact: Json | null; + referral: string | null; + referral_info: Json; + review_due_at: string | null; + route: string | null; + slug: string; + source: Json; + source_status: string; + status_chips: Json; + subtitle: string | null; + summary_cards: Json; + tags: string[]; + title: string; + updated_at: string; + validation_status: string; + verification: Json; + }; Insert: { - best_use?: string | null - catalog_payload?: Json - catalogue_label?: string | null - catchments?: string[] - contacts?: Json - cost?: string | null - created_at?: string - criteria?: Json - eligibility?: string | null - id?: string - kind: string - last_reviewed_at?: string | null - location?: string | null - navigator_query?: string | null - owner_id: string - primary_contact?: Json | null - referral?: string | null - referral_info?: Json - review_due_at?: string | null - route?: string | null - slug: string - source?: Json - source_status?: string - status_chips?: Json - subtitle?: string | null - summary_cards?: Json - tags?: string[] - title: string - updated_at?: string - validation_status?: string - verification?: Json - } + best_use?: string | null; + catalog_payload?: Json; + catalogue_label?: string | null; + catchments?: string[]; + contacts?: Json; + cost?: string | null; + created_at?: string; + criteria?: Json; + eligibility?: string | null; + id?: string; + kind: string; + last_reviewed_at?: string | null; + location?: string | null; + navigator_query?: string | null; + owner_id: string; + primary_contact?: Json | null; + referral?: string | null; + referral_info?: Json; + review_due_at?: string | null; + route?: string | null; + slug: string; + source?: Json; + source_status?: string; + status_chips?: Json; + subtitle?: string | null; + summary_cards?: Json; + tags?: string[]; + title: string; + updated_at?: string; + validation_status?: string; + verification?: Json; + }; Update: { - best_use?: string | null - catalog_payload?: Json - catalogue_label?: string | null - catchments?: string[] - contacts?: Json - cost?: string | null - created_at?: string - criteria?: Json - eligibility?: string | null - id?: string - kind?: string - last_reviewed_at?: string | null - location?: string | null - navigator_query?: string | null - owner_id?: string - primary_contact?: Json | null - referral?: string | null - referral_info?: Json - review_due_at?: string | null - route?: string | null - slug?: string - source?: Json - source_status?: string - status_chips?: Json - subtitle?: string | null - summary_cards?: Json - tags?: string[] - title?: string - updated_at?: string - validation_status?: string - verification?: Json - } - Relationships: [] - } + best_use?: string | null; + catalog_payload?: Json; + catalogue_label?: string | null; + catchments?: string[]; + contacts?: Json; + cost?: string | null; + created_at?: string; + criteria?: Json; + eligibility?: string | null; + id?: string; + kind?: string; + last_reviewed_at?: string | null; + location?: string | null; + navigator_query?: string | null; + owner_id?: string; + primary_contact?: Json | null; + referral?: string | null; + referral_info?: Json; + review_due_at?: string | null; + route?: string | null; + slug?: string; + source?: Json; + source_status?: string; + status_chips?: Json; + subtitle?: string | null; + summary_cards?: Json; + tags?: string[]; + title?: string; + updated_at?: string; + validation_status?: string; + verification?: Json; + }; + Relationships: []; + }; differential_records: { Row: { - clinical_hinge: string | null - created_at: string - id: string - kind: string - last_reviewed_at: string | null - owner_id: string - payload: Json - review_due_at: string | null - slug: string - source: Json - source_status: string - status: string - subtitle: string | null - tags: string[] - title: string - updated_at: string - validation_status: string - } + clinical_hinge: string | null; + created_at: string; + id: string; + kind: string; + last_reviewed_at: string | null; + owner_id: string; + payload: Json; + review_due_at: string | null; + slug: string; + source: Json; + source_status: string; + status: string; + subtitle: string | null; + tags: string[]; + title: string; + updated_at: string; + validation_status: string; + }; Insert: { - clinical_hinge?: string | null - created_at?: string - id?: string - kind: string - last_reviewed_at?: string | null - owner_id: string - payload?: Json - review_due_at?: string | null - slug: string - source?: Json - source_status?: string - status: string - subtitle?: string | null - tags?: string[] - title: string - updated_at?: string - validation_status?: string - } + clinical_hinge?: string | null; + created_at?: string; + id?: string; + kind: string; + last_reviewed_at?: string | null; + owner_id: string; + payload?: Json; + review_due_at?: string | null; + slug: string; + source?: Json; + source_status?: string; + status: string; + subtitle?: string | null; + tags?: string[]; + title: string; + updated_at?: string; + validation_status?: string; + }; Update: { - clinical_hinge?: string | null - created_at?: string - id?: string - kind?: string - last_reviewed_at?: string | null - owner_id?: string - payload?: Json - review_due_at?: string | null - slug?: string - source?: Json - source_status?: string - status?: string - subtitle?: string | null - tags?: string[] - title?: string - updated_at?: string - validation_status?: string - } - Relationships: [] - } + clinical_hinge?: string | null; + created_at?: string; + id?: string; + kind?: string; + last_reviewed_at?: string | null; + owner_id?: string; + payload?: Json; + review_due_at?: string | null; + slug?: string; + source?: Json; + source_status?: string; + status?: string; + subtitle?: string | null; + tags?: string[]; + title?: string; + updated_at?: string; + validation_status?: string; + }; + Relationships: []; + }; document_chunks: { Row: { - anchor_id: string | null - chunk_index: number - content: string - content_hash: string | null - created_at: string - document_id: string - embedding: Vector - heading_level: number | null - id: string - image_ids: string[] - index_generation_id: string | null - metadata: Json - page_number: number | null - parent_heading: string | null - retrieval_synopsis: string | null - search_tsv: unknown - section_heading: string | null - section_path: string[] - token_estimate: number - } + anchor_id: string | null; + chunk_index: number; + content: string; + content_hash: string | null; + created_at: string; + document_id: string; + embedding: Vector; + heading_level: number | null; + id: string; + image_ids: string[]; + index_generation_id: string | null; + metadata: Json; + page_number: number | null; + parent_heading: string | null; + retrieval_synopsis: string | null; + search_tsv: unknown; + section_heading: string | null; + section_path: string[]; + token_estimate: number; + }; Insert: { - anchor_id?: string | null - chunk_index: number - content: string - content_hash?: string | null - created_at?: string - document_id: string - embedding: Vector - heading_level?: number | null - id?: string - image_ids?: string[] - index_generation_id?: string | null - metadata?: Json - page_number?: number | null - parent_heading?: string | null - retrieval_synopsis?: string | null - search_tsv?: unknown - section_heading?: string | null - section_path?: string[] - token_estimate?: number - } + anchor_id?: string | null; + chunk_index: number; + content: string; + content_hash?: string | null; + created_at?: string; + document_id: string; + embedding: Vector; + heading_level?: number | null; + id?: string; + image_ids?: string[]; + index_generation_id?: string | null; + metadata?: Json; + page_number?: number | null; + parent_heading?: string | null; + retrieval_synopsis?: string | null; + search_tsv?: unknown; + section_heading?: string | null; + section_path?: string[]; + token_estimate?: number; + }; Update: { - anchor_id?: string | null - chunk_index?: number - content?: string - content_hash?: string | null - created_at?: string - document_id?: string - embedding?: Vector - heading_level?: number | null - id?: string - image_ids?: string[] - index_generation_id?: string | null - metadata?: Json - page_number?: number | null - parent_heading?: string | null - retrieval_synopsis?: string | null - search_tsv?: unknown - section_heading?: string | null - section_path?: string[] - token_estimate?: number - } + anchor_id?: string | null; + chunk_index?: number; + content?: string; + content_hash?: string | null; + created_at?: string; + document_id?: string; + embedding?: Vector; + heading_level?: number | null; + id?: string; + image_ids?: string[]; + index_generation_id?: string | null; + metadata?: Json; + page_number?: number | null; + parent_heading?: string | null; + retrieval_synopsis?: string | null; + search_tsv?: unknown; + section_heading?: string | null; + section_path?: string[]; + token_estimate?: number; + }; Relationships: [ { - foreignKeyName: "document_chunks_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_chunks_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_chunks_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_chunks_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_embedding_fields: { Row: { - content: string - content_hash: string - created_at: string - document_id: string - embedding: Vector - field_type: string - id: string - metadata: Json - owner_id: string | null - search_tsv: unknown - source_chunk_id: string | null - } + content: string; + content_hash: string; + created_at: string; + document_id: string; + embedding: Vector; + field_type: string; + id: string; + metadata: Json; + owner_id: string | null; + search_tsv: unknown; + source_chunk_id: string | null; + }; Insert: { - content: string - content_hash: string - created_at?: string - document_id: string - embedding: Vector - field_type: string - id?: string - metadata?: Json - owner_id?: string | null - search_tsv?: unknown - source_chunk_id?: string | null - } + content: string; + content_hash: string; + created_at?: string; + document_id: string; + embedding: Vector; + field_type: string; + id?: string; + metadata?: Json; + owner_id?: string | null; + search_tsv?: unknown; + source_chunk_id?: string | null; + }; Update: { - content?: string - content_hash?: string - created_at?: string - document_id?: string - embedding?: Vector - field_type?: string - id?: string - metadata?: Json - owner_id?: string | null - search_tsv?: unknown - source_chunk_id?: string | null - } + content?: string; + content_hash?: string; + created_at?: string; + document_id?: string; + embedding?: Vector; + field_type?: string; + id?: string; + metadata?: Json; + owner_id?: string | null; + search_tsv?: unknown; + source_chunk_id?: string | null; + }; Relationships: [ { - foreignKeyName: "document_embedding_fields_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_embedding_fields_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_embedding_fields_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_embedding_fields_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, { - foreignKeyName: "document_embedding_fields_source_chunk_id_fkey" - columns: ["source_chunk_id"] - isOneToOne: false - referencedRelation: "document_chunks" - referencedColumns: ["id"] + foreignKeyName: "document_embedding_fields_source_chunk_id_fkey"; + columns: ["source_chunk_id"]; + isOneToOne: false; + referencedRelation: "document_chunks"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_images: { Row: { - bbox: Json | null - caption: string - caption_confidence: number | null - clinical_priority_score: number | null - clinical_relevance_score: number - created_at: string - crop_completeness: number | null - document_id: string - height: number | null - id: string - image_hash: string | null - image_quality_score: number | null - image_type: string - labels: string[] - metadata: Json - mime_type: string - ocr_text_density: number | null - page_number: number | null - perceptual_hash: string | null - searchable: boolean - skip_reason: string | null - source_kind: string - storage_path: string - structured_extraction_confidence: number | null - visual_duplicate_group: string | null - width: number | null - } + bbox: Json | null; + caption: string; + caption_confidence: number | null; + clinical_priority_score: number | null; + clinical_relevance_score: number; + created_at: string; + crop_completeness: number | null; + document_id: string; + height: number | null; + id: string; + image_hash: string | null; + image_quality_score: number | null; + image_type: string; + labels: string[]; + metadata: Json; + mime_type: string; + ocr_text_density: number | null; + page_number: number | null; + perceptual_hash: string | null; + searchable: boolean; + skip_reason: string | null; + source_kind: string; + storage_path: string; + structured_extraction_confidence: number | null; + visual_duplicate_group: string | null; + width: number | null; + }; Insert: { - bbox?: Json | null - caption?: string - caption_confidence?: number | null - clinical_priority_score?: number | null - clinical_relevance_score?: number - created_at?: string - crop_completeness?: number | null - document_id: string - height?: number | null - id?: string - image_hash?: string | null - image_quality_score?: number | null - image_type?: string - labels?: string[] - metadata?: Json - mime_type?: string - ocr_text_density?: number | null - page_number?: number | null - perceptual_hash?: string | null - searchable?: boolean - skip_reason?: string | null - source_kind?: string - storage_path: string - structured_extraction_confidence?: number | null - visual_duplicate_group?: string | null - width?: number | null - } + bbox?: Json | null; + caption?: string; + caption_confidence?: number | null; + clinical_priority_score?: number | null; + clinical_relevance_score?: number; + created_at?: string; + crop_completeness?: number | null; + document_id: string; + height?: number | null; + id?: string; + image_hash?: string | null; + image_quality_score?: number | null; + image_type?: string; + labels?: string[]; + metadata?: Json; + mime_type?: string; + ocr_text_density?: number | null; + page_number?: number | null; + perceptual_hash?: string | null; + searchable?: boolean; + skip_reason?: string | null; + source_kind?: string; + storage_path: string; + structured_extraction_confidence?: number | null; + visual_duplicate_group?: string | null; + width?: number | null; + }; Update: { - bbox?: Json | null - caption?: string - caption_confidence?: number | null - clinical_priority_score?: number | null - clinical_relevance_score?: number - created_at?: string - crop_completeness?: number | null - document_id?: string - height?: number | null - id?: string - image_hash?: string | null - image_quality_score?: number | null - image_type?: string - labels?: string[] - metadata?: Json - mime_type?: string - ocr_text_density?: number | null - page_number?: number | null - perceptual_hash?: string | null - searchable?: boolean - skip_reason?: string | null - source_kind?: string - storage_path?: string - structured_extraction_confidence?: number | null - visual_duplicate_group?: string | null - width?: number | null - } + bbox?: Json | null; + caption?: string; + caption_confidence?: number | null; + clinical_priority_score?: number | null; + clinical_relevance_score?: number; + created_at?: string; + crop_completeness?: number | null; + document_id?: string; + height?: number | null; + id?: string; + image_hash?: string | null; + image_quality_score?: number | null; + image_type?: string; + labels?: string[]; + metadata?: Json; + mime_type?: string; + ocr_text_density?: number | null; + page_number?: number | null; + perceptual_hash?: string | null; + searchable?: boolean; + skip_reason?: string | null; + source_kind?: string; + storage_path?: string; + structured_extraction_confidence?: number | null; + visual_duplicate_group?: string | null; + width?: number | null; + }; Relationships: [ { - foreignKeyName: "document_images_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_images_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_images_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_images_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_index_quality: { Row: { - anchor_coverage: number | null - document_id: string - extraction_quality: string - issues: string[] - metrics: Json - model_fallback_rate: number | null - noisy_unit_rate: number | null - owner_id: string | null - quality_score: number - retrievable_visual_hit: boolean | null - source_span_coverage: number | null - typed_unit_coverage: number | null - updated_at: string - } + anchor_coverage: number | null; + document_id: string; + extraction_quality: string; + issues: string[]; + metrics: Json; + model_fallback_rate: number | null; + noisy_unit_rate: number | null; + owner_id: string | null; + quality_score: number; + retrievable_visual_hit: boolean | null; + source_span_coverage: number | null; + typed_unit_coverage: number | null; + updated_at: string; + }; Insert: { - anchor_coverage?: number | null - document_id: string - extraction_quality?: string - issues?: string[] - metrics?: Json - model_fallback_rate?: number | null - noisy_unit_rate?: number | null - owner_id?: string | null - quality_score?: number - retrievable_visual_hit?: boolean | null - source_span_coverage?: number | null - typed_unit_coverage?: number | null - updated_at?: string - } + anchor_coverage?: number | null; + document_id: string; + extraction_quality?: string; + issues?: string[]; + metrics?: Json; + model_fallback_rate?: number | null; + noisy_unit_rate?: number | null; + owner_id?: string | null; + quality_score?: number; + retrievable_visual_hit?: boolean | null; + source_span_coverage?: number | null; + typed_unit_coverage?: number | null; + updated_at?: string; + }; Update: { - anchor_coverage?: number | null - document_id?: string - extraction_quality?: string - issues?: string[] - metrics?: Json - model_fallback_rate?: number | null - noisy_unit_rate?: number | null - owner_id?: string | null - quality_score?: number - retrievable_visual_hit?: boolean | null - source_span_coverage?: number | null - typed_unit_coverage?: number | null - updated_at?: string - } + anchor_coverage?: number | null; + document_id?: string; + extraction_quality?: string; + issues?: string[]; + metrics?: Json; + model_fallback_rate?: number | null; + noisy_unit_rate?: number | null; + owner_id?: string | null; + quality_score?: number; + retrievable_visual_hit?: boolean | null; + source_span_coverage?: number | null; + typed_unit_coverage?: number | null; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "document_index_quality_document_id_fkey" - columns: ["document_id"] - isOneToOne: true - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_index_quality_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: true; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_index_quality_document_id_fkey" - columns: ["document_id"] - isOneToOne: true - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_index_quality_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: true; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_index_units: { Row: { - content: string - created_at: string - document_id: string - embedding: Vector - extraction_mode: string - heading_path: string[] - id: string - metadata: Json - normalized_terms: string[] - owner_id: string | null - page_end: number | null - page_start: number | null - quality_score: number - search_tsv: unknown - source_chunk_id: string | null - source_image_id: string | null - source_span: Json | null - title: string - unit_type: string - updated_at: string - } + content: string; + created_at: string; + document_id: string; + embedding: Vector; + extraction_mode: string; + heading_path: string[]; + id: string; + metadata: Json; + normalized_terms: string[]; + owner_id: string | null; + page_end: number | null; + page_start: number | null; + quality_score: number; + search_tsv: unknown; + source_chunk_id: string | null; + source_image_id: string | null; + source_span: Json | null; + title: string; + unit_type: string; + updated_at: string; + }; Insert: { - content: string - created_at?: string - document_id: string - embedding: Vector - extraction_mode?: string - heading_path?: string[] - id?: string - metadata?: Json - normalized_terms?: string[] - owner_id?: string | null - page_end?: number | null - page_start?: number | null - quality_score?: number - search_tsv?: unknown - source_chunk_id?: string | null - source_image_id?: string | null - source_span?: Json | null - title: string - unit_type: string - updated_at?: string - } + content: string; + created_at?: string; + document_id: string; + embedding: Vector; + extraction_mode?: string; + heading_path?: string[]; + id?: string; + metadata?: Json; + normalized_terms?: string[]; + owner_id?: string | null; + page_end?: number | null; + page_start?: number | null; + quality_score?: number; + search_tsv?: unknown; + source_chunk_id?: string | null; + source_image_id?: string | null; + source_span?: Json | null; + title: string; + unit_type: string; + updated_at?: string; + }; Update: { - content?: string - created_at?: string - document_id?: string - embedding?: Vector - extraction_mode?: string - heading_path?: string[] - id?: string - metadata?: Json - normalized_terms?: string[] - owner_id?: string | null - page_end?: number | null - page_start?: number | null - quality_score?: number - search_tsv?: unknown - source_chunk_id?: string | null - source_image_id?: string | null - source_span?: Json | null - title?: string - unit_type?: string - updated_at?: string - } + content?: string; + created_at?: string; + document_id?: string; + embedding?: Vector; + extraction_mode?: string; + heading_path?: string[]; + id?: string; + metadata?: Json; + normalized_terms?: string[]; + owner_id?: string | null; + page_end?: number | null; + page_start?: number | null; + quality_score?: number; + search_tsv?: unknown; + source_chunk_id?: string | null; + source_image_id?: string | null; + source_span?: Json | null; + title?: string; + unit_type?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "document_index_units_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_index_units_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_index_units_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_index_units_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, { - foreignKeyName: "document_index_units_source_chunk_id_fkey" - columns: ["source_chunk_id"] - isOneToOne: false - referencedRelation: "document_chunks" - referencedColumns: ["id"] + foreignKeyName: "document_index_units_source_chunk_id_fkey"; + columns: ["source_chunk_id"]; + isOneToOne: false; + referencedRelation: "document_chunks"; + referencedColumns: ["id"]; }, { - foreignKeyName: "document_index_units_source_image_id_fkey" - columns: ["source_image_id"] - isOneToOne: false - referencedRelation: "document_images" - referencedColumns: ["id"] + foreignKeyName: "document_index_units_source_image_id_fkey"; + columns: ["source_image_id"]; + isOneToOne: false; + referencedRelation: "document_images"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_labels: { Row: { - confidence: number - created_at: string - document_id: string - id: string - label: string - label_type: string - metadata: Json - owner_id: string | null - source: string - updated_at: string - } + confidence: number; + created_at: string; + document_id: string; + id: string; + label: string; + label_type: string; + metadata: Json; + owner_id: string | null; + source: string; + updated_at: string; + }; Insert: { - confidence?: number - created_at?: string - document_id: string - id?: string - label: string - label_type: string - metadata?: Json - owner_id?: string | null - source?: string - updated_at?: string - } + confidence?: number; + created_at?: string; + document_id: string; + id?: string; + label: string; + label_type: string; + metadata?: Json; + owner_id?: string | null; + source?: string; + updated_at?: string; + }; Update: { - confidence?: number - created_at?: string - document_id?: string - id?: string - label?: string - label_type?: string - metadata?: Json - owner_id?: string | null - source?: string - updated_at?: string - } + confidence?: number; + created_at?: string; + document_id?: string; + id?: string; + label?: string; + label_type?: string; + metadata?: Json; + owner_id?: string | null; + source?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "document_labels_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_labels_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_labels_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_labels_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_memory_cards: { Row: { - card_type: string - confidence: number - content: string - created_at: string - document_id: string - embedding: Vector - id: string - metadata: Json - normalized_terms: string[] - owner_id: string | null - page_number: number | null - search_tsv: unknown - section_id: string | null - source_chunk_ids: string[] - source_image_ids: string[] - title: string - updated_at: string - } + card_type: string; + confidence: number; + content: string; + created_at: string; + document_id: string; + embedding: Vector; + id: string; + metadata: Json; + normalized_terms: string[]; + owner_id: string | null; + page_number: number | null; + search_tsv: unknown; + section_id: string | null; + source_chunk_ids: string[]; + source_image_ids: string[]; + title: string; + updated_at: string; + }; Insert: { - card_type: string - confidence?: number - content: string - created_at?: string - document_id: string - embedding: Vector - id?: string - metadata?: Json - normalized_terms?: string[] - owner_id?: string | null - page_number?: number | null - search_tsv?: unknown - section_id?: string | null - source_chunk_ids?: string[] - source_image_ids?: string[] - title: string - updated_at?: string - } + card_type: string; + confidence?: number; + content: string; + created_at?: string; + document_id: string; + embedding: Vector; + id?: string; + metadata?: Json; + normalized_terms?: string[]; + owner_id?: string | null; + page_number?: number | null; + search_tsv?: unknown; + section_id?: string | null; + source_chunk_ids?: string[]; + source_image_ids?: string[]; + title: string; + updated_at?: string; + }; Update: { - card_type?: string - confidence?: number - content?: string - created_at?: string - document_id?: string - embedding?: Vector - id?: string - metadata?: Json - normalized_terms?: string[] - owner_id?: string | null - page_number?: number | null - search_tsv?: unknown - section_id?: string | null - source_chunk_ids?: string[] - source_image_ids?: string[] - title?: string - updated_at?: string - } + card_type?: string; + confidence?: number; + content?: string; + created_at?: string; + document_id?: string; + embedding?: Vector; + id?: string; + metadata?: Json; + normalized_terms?: string[]; + owner_id?: string | null; + page_number?: number | null; + search_tsv?: unknown; + section_id?: string | null; + source_chunk_ids?: string[]; + source_image_ids?: string[]; + title?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "document_memory_cards_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_memory_cards_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_memory_cards_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_memory_cards_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, { - foreignKeyName: "document_memory_cards_section_id_fkey" - columns: ["section_id"] - isOneToOne: false - referencedRelation: "document_sections" - referencedColumns: ["id"] + foreignKeyName: "document_memory_cards_section_id_fkey"; + columns: ["section_id"]; + isOneToOne: false; + referencedRelation: "document_sections"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_pages: { Row: { - created_at: string - document_id: string - id: string - metadata: Json - ocr_used: boolean - page_number: number - text: string - } + created_at: string; + document_id: string; + id: string; + metadata: Json; + ocr_used: boolean; + page_number: number; + text: string; + }; Insert: { - created_at?: string - document_id: string - id?: string - metadata?: Json - ocr_used?: boolean - page_number: number - text?: string - } + created_at?: string; + document_id: string; + id?: string; + metadata?: Json; + ocr_used?: boolean; + page_number: number; + text?: string; + }; Update: { - created_at?: string - document_id?: string - id?: string - metadata?: Json - ocr_used?: boolean - page_number?: number - text?: string - } + created_at?: string; + document_id?: string; + id?: string; + metadata?: Json; + ocr_used?: boolean; + page_number?: number; + text?: string; + }; Relationships: [ { - foreignKeyName: "document_pages_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_pages_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_pages_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_pages_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_sections: { Row: { - chunk_ids: string[] - created_at: string - document_id: string - extraction_quality: string - heading: string - heading_path: string[] - id: string - metadata: Json - owner_id: string | null - page_end: number | null - page_start: number | null - section_index: number - summary: string - tags: string[] - updated_at: string - } + chunk_ids: string[]; + created_at: string; + document_id: string; + extraction_quality: string; + heading: string; + heading_path: string[]; + id: string; + metadata: Json; + owner_id: string | null; + page_end: number | null; + page_start: number | null; + section_index: number; + summary: string; + tags: string[]; + updated_at: string; + }; Insert: { - chunk_ids?: string[] - created_at?: string - document_id: string - extraction_quality?: string - heading: string - heading_path?: string[] - id?: string - metadata?: Json - owner_id?: string | null - page_end?: number | null - page_start?: number | null - section_index: number - summary?: string - tags?: string[] - updated_at?: string - } + chunk_ids?: string[]; + created_at?: string; + document_id: string; + extraction_quality?: string; + heading: string; + heading_path?: string[]; + id?: string; + metadata?: Json; + owner_id?: string | null; + page_end?: number | null; + page_start?: number | null; + section_index: number; + summary?: string; + tags?: string[]; + updated_at?: string; + }; Update: { - chunk_ids?: string[] - created_at?: string - document_id?: string - extraction_quality?: string - heading?: string - heading_path?: string[] - id?: string - metadata?: Json - owner_id?: string | null - page_end?: number | null - page_start?: number | null - section_index?: number - summary?: string - tags?: string[] - updated_at?: string - } + chunk_ids?: string[]; + created_at?: string; + document_id?: string; + extraction_quality?: string; + heading?: string; + heading_path?: string[]; + id?: string; + metadata?: Json; + owner_id?: string | null; + page_end?: number | null; + page_start?: number | null; + section_index?: number; + summary?: string; + tags?: string[]; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "document_sections_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_sections_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_sections_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_sections_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_summaries: { Row: { - clinical_specifics: Json - created_at: string - document_id: string - generated_at: string - id: string - metadata: Json - model: string | null - owner_id: string | null - source_chunk_ids: string[] - source_image_ids: string[] - summary: string - updated_at: string - } + clinical_specifics: Json; + created_at: string; + document_id: string; + generated_at: string; + id: string; + metadata: Json; + model: string | null; + owner_id: string | null; + source_chunk_ids: string[]; + source_image_ids: string[]; + summary: string; + updated_at: string; + }; Insert: { - clinical_specifics?: Json - created_at?: string - document_id: string - generated_at?: string - id?: string - metadata?: Json - model?: string | null - owner_id?: string | null - source_chunk_ids?: string[] - source_image_ids?: string[] - summary: string - updated_at?: string - } + clinical_specifics?: Json; + created_at?: string; + document_id: string; + generated_at?: string; + id?: string; + metadata?: Json; + model?: string | null; + owner_id?: string | null; + source_chunk_ids?: string[]; + source_image_ids?: string[]; + summary: string; + updated_at?: string; + }; Update: { - clinical_specifics?: Json - created_at?: string - document_id?: string - generated_at?: string - id?: string - metadata?: Json - model?: string | null - owner_id?: string | null - source_chunk_ids?: string[] - source_image_ids?: string[] - summary?: string - updated_at?: string - } + clinical_specifics?: Json; + created_at?: string; + document_id?: string; + generated_at?: string; + id?: string; + metadata?: Json; + model?: string | null; + owner_id?: string | null; + source_chunk_ids?: string[]; + source_image_ids?: string[]; + summary?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "document_summaries_document_id_fkey" - columns: ["document_id"] - isOneToOne: true - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_summaries_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: true; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_summaries_document_id_fkey" - columns: ["document_id"] - isOneToOne: true - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_summaries_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: true; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; document_table_facts: { Row: { - action: string | null - clinical_parameter: string | null - created_at: string - document_id: string - id: string - metadata: Json - normalized_terms: string[] - owner_id: string | null - page_number: number | null - row_label: string | null - search_tsv: unknown - source_chunk_id: string | null - source_image_id: string | null - table_title: string | null - threshold_value: string | null - } + action: string | null; + clinical_parameter: string | null; + created_at: string; + document_id: string; + id: string; + metadata: Json; + normalized_terms: string[]; + owner_id: string | null; + page_number: number | null; + row_label: string | null; + search_tsv: unknown; + source_chunk_id: string | null; + source_image_id: string | null; + table_title: string | null; + threshold_value: string | null; + }; Insert: { - action?: string | null - clinical_parameter?: string | null - created_at?: string - document_id: string - id?: string - metadata?: Json - normalized_terms?: string[] - owner_id?: string | null - page_number?: number | null - row_label?: string | null - search_tsv?: unknown - source_chunk_id?: string | null - source_image_id?: string | null - table_title?: string | null - threshold_value?: string | null - } + action?: string | null; + clinical_parameter?: string | null; + created_at?: string; + document_id: string; + id?: string; + metadata?: Json; + normalized_terms?: string[]; + owner_id?: string | null; + page_number?: number | null; + row_label?: string | null; + search_tsv?: unknown; + source_chunk_id?: string | null; + source_image_id?: string | null; + table_title?: string | null; + threshold_value?: string | null; + }; Update: { - action?: string | null - clinical_parameter?: string | null - created_at?: string - document_id?: string - id?: string - metadata?: Json - normalized_terms?: string[] - owner_id?: string | null - page_number?: number | null - row_label?: string | null - search_tsv?: unknown - source_chunk_id?: string | null - source_image_id?: string | null - table_title?: string | null - threshold_value?: string | null - } + action?: string | null; + clinical_parameter?: string | null; + created_at?: string; + document_id?: string; + id?: string; + metadata?: Json; + normalized_terms?: string[]; + owner_id?: string | null; + page_number?: number | null; + row_label?: string | null; + search_tsv?: unknown; + source_chunk_id?: string | null; + source_image_id?: string | null; + table_title?: string | null; + threshold_value?: string | null; + }; Relationships: [ { - foreignKeyName: "document_table_facts_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "document_table_facts_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "document_table_facts_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "document_table_facts_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, { - foreignKeyName: "document_table_facts_source_chunk_id_fkey" - columns: ["source_chunk_id"] - isOneToOne: false - referencedRelation: "document_chunks" - referencedColumns: ["id"] + foreignKeyName: "document_table_facts_source_chunk_id_fkey"; + columns: ["source_chunk_id"]; + isOneToOne: false; + referencedRelation: "document_chunks"; + referencedColumns: ["id"]; }, { - foreignKeyName: "document_table_facts_source_image_id_fkey" - columns: ["source_image_id"] - isOneToOne: false - referencedRelation: "document_images" - referencedColumns: ["id"] + foreignKeyName: "document_table_facts_source_image_id_fkey"; + columns: ["source_image_id"]; + isOneToOne: false; + referencedRelation: "document_images"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; documents: { Row: { - chunk_count: number - content_hash: string | null - created_at: string - description: string | null - error_message: string | null - file_name: string - file_size: number - file_type: string - id: string - image_count: number - import_batch_id: string | null - metadata: Json - owner_id: string | null - page_count: number - search_tsv: unknown - source_path: string | null - status: string - storage_path: string - title: string - title_search_tsv: unknown - updated_at: string - } + chunk_count: number; + content_hash: string | null; + created_at: string; + description: string | null; + error_message: string | null; + file_name: string; + file_size: number; + file_type: string; + id: string; + image_count: number; + import_batch_id: string | null; + metadata: Json; + owner_id: string | null; + page_count: number; + search_tsv: unknown; + source_path: string | null; + status: string; + storage_path: string; + title: string; + title_search_tsv: unknown; + updated_at: string; + }; Insert: { - chunk_count?: number - content_hash?: string | null - created_at?: string - description?: string | null - error_message?: string | null - file_name: string - file_size?: number - file_type: string - id?: string - image_count?: number - import_batch_id?: string | null - metadata?: Json - owner_id?: string | null - page_count?: number - search_tsv?: unknown - source_path?: string | null - status?: string - storage_path: string - title: string - title_search_tsv?: unknown - updated_at?: string - } + chunk_count?: number; + content_hash?: string | null; + created_at?: string; + description?: string | null; + error_message?: string | null; + file_name: string; + file_size?: number; + file_type: string; + id?: string; + image_count?: number; + import_batch_id?: string | null; + metadata?: Json; + owner_id?: string | null; + page_count?: number; + search_tsv?: unknown; + source_path?: string | null; + status?: string; + storage_path: string; + title: string; + title_search_tsv?: unknown; + updated_at?: string; + }; Update: { - chunk_count?: number - content_hash?: string | null - created_at?: string - description?: string | null - error_message?: string | null - file_name?: string - file_size?: number - file_type?: string - id?: string - image_count?: number - import_batch_id?: string | null - metadata?: Json - owner_id?: string | null - page_count?: number - search_tsv?: unknown - source_path?: string | null - status?: string - storage_path?: string - title?: string - title_search_tsv?: unknown - updated_at?: string - } + chunk_count?: number; + content_hash?: string | null; + created_at?: string; + description?: string | null; + error_message?: string | null; + file_name?: string; + file_size?: number; + file_type?: string; + id?: string; + image_count?: number; + import_batch_id?: string | null; + metadata?: Json; + owner_id?: string | null; + page_count?: number; + search_tsv?: unknown; + source_path?: string | null; + status?: string; + storage_path?: string; + title?: string; + title_search_tsv?: unknown; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "documents_import_batch_id_fkey" - columns: ["import_batch_id"] - isOneToOne: false - referencedRelation: "import_batches" - referencedColumns: ["id"] + foreignKeyName: "documents_import_batch_id_fkey"; + columns: ["import_batch_id"]; + isOneToOne: false; + referencedRelation: "import_batches"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; image_caption_cache: { Row: { - caption: string - created_at: string - id: string - image_hash: string - metadata: Json - mime_type: string | null - model: string - owner_id: string - updated_at: string - } + caption: string; + created_at: string; + id: string; + image_hash: string; + metadata: Json; + mime_type: string | null; + model: string; + owner_id: string; + updated_at: string; + }; Insert: { - caption: string - created_at?: string - id?: string - image_hash: string - metadata?: Json - mime_type?: string | null - model: string - owner_id: string - updated_at?: string - } + caption: string; + created_at?: string; + id?: string; + image_hash: string; + metadata?: Json; + mime_type?: string | null; + model: string; + owner_id: string; + updated_at?: string; + }; Update: { - caption?: string - created_at?: string - id?: string - image_hash?: string - metadata?: Json - mime_type?: string | null - model?: string - owner_id?: string - updated_at?: string - } - Relationships: [] - } + caption?: string; + created_at?: string; + id?: string; + image_hash?: string; + metadata?: Json; + mime_type?: string | null; + model?: string; + owner_id?: string; + updated_at?: string; + }; + Relationships: []; + }; import_batches: { Row: { - completed_at: string | null - created_at: string - failed_files: number - id: string - include_glob: string - metadata: Json - name: string - owner_id: string | null - queued_files: number - skipped_files: number - source_root: string | null - status: string - total_bytes: number - total_files: number - updated_at: string - } + completed_at: string | null; + created_at: string; + failed_files: number; + id: string; + include_glob: string; + metadata: Json; + name: string; + owner_id: string | null; + queued_files: number; + skipped_files: number; + source_root: string | null; + status: string; + total_bytes: number; + total_files: number; + updated_at: string; + }; Insert: { - completed_at?: string | null - created_at?: string - failed_files?: number - id?: string - include_glob?: string - metadata?: Json - name: string - owner_id?: string | null - queued_files?: number - skipped_files?: number - source_root?: string | null - status?: string - total_bytes?: number - total_files?: number - updated_at?: string - } + completed_at?: string | null; + created_at?: string; + failed_files?: number; + id?: string; + include_glob?: string; + metadata?: Json; + name: string; + owner_id?: string | null; + queued_files?: number; + skipped_files?: number; + source_root?: string | null; + status?: string; + total_bytes?: number; + total_files?: number; + updated_at?: string; + }; Update: { - completed_at?: string | null - created_at?: string - failed_files?: number - id?: string - include_glob?: string - metadata?: Json - name?: string - owner_id?: string | null - queued_files?: number - skipped_files?: number - source_root?: string | null - status?: string - total_bytes?: number - total_files?: number - updated_at?: string - } - Relationships: [] - } + completed_at?: string | null; + created_at?: string; + failed_files?: number; + id?: string; + include_glob?: string; + metadata?: Json; + name?: string; + owner_id?: string | null; + queued_files?: number; + skipped_files?: number; + source_root?: string | null; + status?: string; + total_bytes?: number; + total_files?: number; + updated_at?: string; + }; + Relationships: []; + }; ingestion_job_stages: { Row: { - artifact_counts: Json - created_at: string - document_id: string - error_class: string | null - error_message: string | null - finished_at: string | null - id: string - job_id: string - metadata: Json - retry_count: number - stage_name: string - stage_status: string - started_at: string - } + artifact_counts: Json; + created_at: string; + document_id: string; + error_class: string | null; + error_message: string | null; + finished_at: string | null; + id: string; + job_id: string; + metadata: Json; + retry_count: number; + stage_name: string; + stage_status: string; + started_at: string; + }; Insert: { - artifact_counts?: Json - created_at?: string - document_id: string - error_class?: string | null - error_message?: string | null - finished_at?: string | null - id?: string - job_id: string - metadata?: Json - retry_count?: number - stage_name: string - stage_status?: string - started_at?: string - } + artifact_counts?: Json; + created_at?: string; + document_id: string; + error_class?: string | null; + error_message?: string | null; + finished_at?: string | null; + id?: string; + job_id: string; + metadata?: Json; + retry_count?: number; + stage_name: string; + stage_status?: string; + started_at?: string; + }; Update: { - artifact_counts?: Json - created_at?: string - document_id?: string - error_class?: string | null - error_message?: string | null - finished_at?: string | null - id?: string - job_id?: string - metadata?: Json - retry_count?: number - stage_name?: string - stage_status?: string - started_at?: string - } + artifact_counts?: Json; + created_at?: string; + document_id?: string; + error_class?: string | null; + error_message?: string | null; + finished_at?: string | null; + id?: string; + job_id?: string; + metadata?: Json; + retry_count?: number; + stage_name?: string; + stage_status?: string; + started_at?: string; + }; Relationships: [ { - foreignKeyName: "ingestion_job_stages_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "ingestion_job_stages_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "ingestion_job_stages_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "ingestion_job_stages_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; ingestion_jobs: { Row: { - attempt_count: number - batch_id: string | null - completed_at: string | null - created_at: string - document_id: string - error_message: string | null - id: string - locked_at: string | null - locked_by: string | null - max_attempts: number - next_run_at: string - progress: number - stage: string - started_at: string | null - status: string - updated_at: string - } + attempt_count: number; + batch_id: string | null; + completed_at: string | null; + created_at: string; + document_id: string; + error_message: string | null; + id: string; + locked_at: string | null; + locked_by: string | null; + max_attempts: number; + next_run_at: string; + progress: number; + stage: string; + started_at: string | null; + status: string; + updated_at: string; + }; Insert: { - attempt_count?: number - batch_id?: string | null - completed_at?: string | null - created_at?: string - document_id: string - error_message?: string | null - id?: string - locked_at?: string | null - locked_by?: string | null - max_attempts?: number - next_run_at?: string - progress?: number - stage?: string - started_at?: string | null - status?: string - updated_at?: string - } + attempt_count?: number; + batch_id?: string | null; + completed_at?: string | null; + created_at?: string; + document_id: string; + error_message?: string | null; + id?: string; + locked_at?: string | null; + locked_by?: string | null; + max_attempts?: number; + next_run_at?: string; + progress?: number; + stage?: string; + started_at?: string | null; + status?: string; + updated_at?: string; + }; Update: { - attempt_count?: number - batch_id?: string | null - completed_at?: string | null - created_at?: string - document_id?: string - error_message?: string | null - id?: string - locked_at?: string | null - locked_by?: string | null - max_attempts?: number - next_run_at?: string - progress?: number - stage?: string - started_at?: string | null - status?: string - updated_at?: string - } + attempt_count?: number; + batch_id?: string | null; + completed_at?: string | null; + created_at?: string; + document_id?: string; + error_message?: string | null; + id?: string; + locked_at?: string | null; + locked_by?: string | null; + max_attempts?: number; + next_run_at?: string; + progress?: number; + stage?: string; + started_at?: string | null; + status?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "ingestion_jobs_batch_id_fkey" - columns: ["batch_id"] - isOneToOne: false - referencedRelation: "import_batches" - referencedColumns: ["id"] + foreignKeyName: "ingestion_jobs_batch_id_fkey"; + columns: ["batch_id"]; + isOneToOne: false; + referencedRelation: "import_batches"; + referencedColumns: ["id"]; }, { - foreignKeyName: "ingestion_jobs_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "ingestion_jobs_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "ingestion_jobs_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "ingestion_jobs_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; medication_records: { Row: { - accent: string | null - category: string | null - class: string | null - created_at: string - id: string - last_reviewed_at: string | null - name: string - owner_id: string - quick: Json - review_due_at: string | null - schedule: string | null - sections: Json - slug: string - source_status: string - stats: Json - subclass: string | null - tag: string | null - updated_at: string - validation_status: string - } + accent: string | null; + category: string | null; + class: string | null; + created_at: string; + id: string; + last_reviewed_at: string | null; + name: string; + owner_id: string; + quick: Json; + review_due_at: string | null; + schedule: string | null; + sections: Json; + slug: string; + source_status: string; + stats: Json; + subclass: string | null; + tag: string | null; + updated_at: string; + validation_status: string; + }; Insert: { - accent?: string | null - category?: string | null - class?: string | null - created_at?: string - id?: string - last_reviewed_at?: string | null - name: string - owner_id: string - quick?: Json - review_due_at?: string | null - schedule?: string | null - sections?: Json - slug: string - source_status?: string - stats?: Json - subclass?: string | null - tag?: string | null - updated_at?: string - validation_status?: string - } + accent?: string | null; + category?: string | null; + class?: string | null; + created_at?: string; + id?: string; + last_reviewed_at?: string | null; + name: string; + owner_id: string; + quick?: Json; + review_due_at?: string | null; + schedule?: string | null; + sections?: Json; + slug: string; + source_status?: string; + stats?: Json; + subclass?: string | null; + tag?: string | null; + updated_at?: string; + validation_status?: string; + }; Update: { - accent?: string | null - category?: string | null - class?: string | null - created_at?: string - id?: string - last_reviewed_at?: string | null - name?: string - owner_id?: string - quick?: Json - review_due_at?: string | null - schedule?: string | null - sections?: Json - slug?: string - source_status?: string - stats?: Json - subclass?: string | null - tag?: string | null - updated_at?: string - validation_status?: string - } - Relationships: [] - } + accent?: string | null; + category?: string | null; + class?: string | null; + created_at?: string; + id?: string; + last_reviewed_at?: string | null; + name?: string; + owner_id?: string; + quick?: Json; + review_due_at?: string | null; + schedule?: string | null; + sections?: Json; + slug?: string; + source_status?: string; + stats?: Json; + subclass?: string | null; + tag?: string | null; + updated_at?: string; + validation_status?: string; + }; + Relationships: []; + }; rag_aliases: { Row: { - alias: string - alias_type: string - canonical: string - created_at: string - enabled: boolean - id: string - metadata: Json - owner_id: string | null - updated_at: string - weight: number - } + alias: string; + alias_type: string; + canonical: string; + created_at: string; + enabled: boolean; + id: string; + metadata: Json; + owner_id: string | null; + updated_at: string; + weight: number; + }; Insert: { - alias: string - alias_type: string - canonical: string - created_at?: string - enabled?: boolean - id?: string - metadata?: Json - owner_id?: string | null - updated_at?: string - weight?: number - } + alias: string; + alias_type: string; + canonical: string; + created_at?: string; + enabled?: boolean; + id?: string; + metadata?: Json; + owner_id?: string | null; + updated_at?: string; + weight?: number; + }; Update: { - alias?: string - alias_type?: string - canonical?: string - created_at?: string - enabled?: boolean - id?: string - metadata?: Json - owner_id?: string | null - updated_at?: string - weight?: number - } - Relationships: [] - } + alias?: string; + alias_type?: string; + canonical?: string; + created_at?: string; + enabled?: boolean; + id?: string; + metadata?: Json; + owner_id?: string | null; + updated_at?: string; + weight?: number; + }; + Relationships: []; + }; rag_queries: { Row: { - answer: string | null - created_at: string - id: string - metadata: Json - model: string | null - owner_id: string | null - query: string - source_chunk_ids: string[] - } + answer: string | null; + created_at: string; + id: string; + metadata: Json; + model: string | null; + owner_id: string | null; + query: string; + source_chunk_ids: string[]; + }; Insert: { - answer?: string | null - created_at?: string - id?: string - metadata?: Json - model?: string | null - owner_id?: string | null - query: string - source_chunk_ids?: string[] - } + answer?: string | null; + created_at?: string; + id?: string; + metadata?: Json; + model?: string | null; + owner_id?: string | null; + query: string; + source_chunk_ids?: string[]; + }; Update: { - answer?: string | null - created_at?: string - id?: string - metadata?: Json - model?: string | null - owner_id?: string | null - query?: string - source_chunk_ids?: string[] - } - Relationships: [] - } + answer?: string | null; + created_at?: string; + id?: string; + metadata?: Json; + model?: string | null; + owner_id?: string | null; + query?: string; + source_chunk_ids?: string[]; + }; + Relationships: []; + }; rag_query_misses: { Row: { - candidate_aliases: string[] - candidate_labels: Json - cited_chunk_ids: string[] - clicked_chunk_id: string | null - clicked_document_id: string | null - created_at: string - expected_chunk_id: string | null - expected_document_id: string | null - expected_file: string | null - id: string - metadata: Json - miss_reason: string - normalized_query: string - owner_id: string | null - promoted_at: string | null - promoted_eval_case: boolean - query: string - query_class: string | null - retrieval_strategy: string | null - review_notes: string | null - review_status: string - reviewed_at: string | null - route: string | null - top_chunk_ids: string[] - top_files: string[] - top_score: number | null - } + candidate_aliases: string[]; + candidate_labels: Json; + cited_chunk_ids: string[]; + clicked_chunk_id: string | null; + clicked_document_id: string | null; + created_at: string; + expected_chunk_id: string | null; + expected_document_id: string | null; + expected_file: string | null; + id: string; + metadata: Json; + miss_reason: string; + normalized_query: string; + owner_id: string | null; + promoted_at: string | null; + promoted_eval_case: boolean; + query: string; + query_class: string | null; + retrieval_strategy: string | null; + review_notes: string | null; + review_status: string; + reviewed_at: string | null; + route: string | null; + top_chunk_ids: string[]; + top_files: string[]; + top_score: number | null; + }; Insert: { - candidate_aliases?: string[] - candidate_labels?: Json - cited_chunk_ids?: string[] - clicked_chunk_id?: string | null - clicked_document_id?: string | null - created_at?: string - expected_chunk_id?: string | null - expected_document_id?: string | null - expected_file?: string | null - id?: string - metadata?: Json - miss_reason?: string - normalized_query: string - owner_id?: string | null - promoted_at?: string | null - promoted_eval_case?: boolean - query: string - query_class?: string | null - retrieval_strategy?: string | null - review_notes?: string | null - review_status?: string - reviewed_at?: string | null - route?: string | null - top_chunk_ids?: string[] - top_files?: string[] - top_score?: number | null - } + candidate_aliases?: string[]; + candidate_labels?: Json; + cited_chunk_ids?: string[]; + clicked_chunk_id?: string | null; + clicked_document_id?: string | null; + created_at?: string; + expected_chunk_id?: string | null; + expected_document_id?: string | null; + expected_file?: string | null; + id?: string; + metadata?: Json; + miss_reason?: string; + normalized_query: string; + owner_id?: string | null; + promoted_at?: string | null; + promoted_eval_case?: boolean; + query: string; + query_class?: string | null; + retrieval_strategy?: string | null; + review_notes?: string | null; + review_status?: string; + reviewed_at?: string | null; + route?: string | null; + top_chunk_ids?: string[]; + top_files?: string[]; + top_score?: number | null; + }; Update: { - candidate_aliases?: string[] - candidate_labels?: Json - cited_chunk_ids?: string[] - clicked_chunk_id?: string | null - clicked_document_id?: string | null - created_at?: string - expected_chunk_id?: string | null - expected_document_id?: string | null - expected_file?: string | null - id?: string - metadata?: Json - miss_reason?: string - normalized_query?: string - owner_id?: string | null - promoted_at?: string | null - promoted_eval_case?: boolean - query?: string - query_class?: string | null - retrieval_strategy?: string | null - review_notes?: string | null - review_status?: string - reviewed_at?: string | null - route?: string | null - top_chunk_ids?: string[] - top_files?: string[] - top_score?: number | null - } + candidate_aliases?: string[]; + candidate_labels?: Json; + cited_chunk_ids?: string[]; + clicked_chunk_id?: string | null; + clicked_document_id?: string | null; + created_at?: string; + expected_chunk_id?: string | null; + expected_document_id?: string | null; + expected_file?: string | null; + id?: string; + metadata?: Json; + miss_reason?: string; + normalized_query?: string; + owner_id?: string | null; + promoted_at?: string | null; + promoted_eval_case?: boolean; + query?: string; + query_class?: string | null; + retrieval_strategy?: string | null; + review_notes?: string | null; + review_status?: string; + reviewed_at?: string | null; + route?: string | null; + top_chunk_ids?: string[]; + top_files?: string[]; + top_score?: number | null; + }; Relationships: [ { - foreignKeyName: "rag_query_misses_expected_chunk_id_fkey" - columns: ["expected_chunk_id"] - isOneToOne: false - referencedRelation: "document_chunks" - referencedColumns: ["id"] + foreignKeyName: "rag_query_misses_expected_chunk_id_fkey"; + columns: ["expected_chunk_id"]; + isOneToOne: false; + referencedRelation: "document_chunks"; + referencedColumns: ["id"]; }, { - foreignKeyName: "rag_query_misses_expected_document_id_fkey" - columns: ["expected_document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "rag_query_misses_expected_document_id_fkey"; + columns: ["expected_document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "rag_query_misses_expected_document_id_fkey" - columns: ["expected_document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "rag_query_misses_expected_document_id_fkey"; + columns: ["expected_document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; rag_response_cache: { Row: { - cache_kind: string - created_at: string - dependency_version: string - expires_at: string - id: string - indexing_version: string - normalized_query: string - owner_id: string | null - payload: Json - scope_key: string - updated_at: string - } + cache_kind: string; + created_at: string; + dependency_version: string; + expires_at: string; + id: string; + indexing_version: string; + normalized_query: string; + owner_id: string | null; + payload: Json; + scope_key: string; + updated_at: string; + }; Insert: { - cache_kind: string - created_at?: string - dependency_version?: string - expires_at: string - id?: string - indexing_version?: string - normalized_query: string - owner_id?: string | null - payload: Json - scope_key: string - updated_at?: string - } + cache_kind: string; + created_at?: string; + dependency_version?: string; + expires_at: string; + id?: string; + indexing_version?: string; + normalized_query: string; + owner_id?: string | null; + payload: Json; + scope_key: string; + updated_at?: string; + }; Update: { - cache_kind?: string - created_at?: string - dependency_version?: string - expires_at?: string - id?: string - indexing_version?: string - normalized_query?: string - owner_id?: string | null - payload?: Json - scope_key?: string - updated_at?: string - } - Relationships: [] - } + cache_kind?: string; + created_at?: string; + dependency_version?: string; + expires_at?: string; + id?: string; + indexing_version?: string; + normalized_query?: string; + owner_id?: string | null; + payload?: Json; + scope_key?: string; + updated_at?: string; + }; + Relationships: []; + }; rag_retrieval_logs: { Row: { - candidate_count: number - created_at: string - embedding_cache_hit: boolean | null - embedding_field_count: number | null - embedding_latency_ms: number | null - id: string - index_unit_count: number | null - is_miss: boolean - mean_hybrid_score: number | null - memory_card_count: number | null - metadata: Json - miss_reason: string | null - normalized_query: string | null - owner_id: string | null - query: string - query_class: string | null - rerank_latency_ms: number | null - retrieval_strategy: string | null - rpc_latency_ms: number | null - selected_chunk_ids: string[] - selected_count: number - selected_document_ids: string[] - text_candidate_count: number | null - top_hybrid_score: number | null - top_rrf_score: number | null - top_similarity: number | null - top_text_rank: number | null - total_latency_ms: number | null - vector_candidate_count: number | null - } + candidate_count: number; + created_at: string; + embedding_cache_hit: boolean | null; + embedding_field_count: number | null; + embedding_latency_ms: number | null; + id: string; + index_unit_count: number | null; + is_miss: boolean; + mean_hybrid_score: number | null; + memory_card_count: number | null; + metadata: Json; + miss_reason: string | null; + normalized_query: string | null; + owner_id: string | null; + query: string; + query_class: string | null; + rerank_latency_ms: number | null; + retrieval_strategy: string | null; + rpc_latency_ms: number | null; + selected_chunk_ids: string[]; + selected_count: number; + selected_document_ids: string[]; + text_candidate_count: number | null; + top_hybrid_score: number | null; + top_rrf_score: number | null; + top_similarity: number | null; + top_text_rank: number | null; + total_latency_ms: number | null; + vector_candidate_count: number | null; + }; Insert: { - candidate_count?: number - created_at?: string - embedding_cache_hit?: boolean | null - embedding_field_count?: number | null - embedding_latency_ms?: number | null - id?: string - index_unit_count?: number | null - is_miss?: boolean - mean_hybrid_score?: number | null - memory_card_count?: number | null - metadata?: Json - miss_reason?: string | null - normalized_query?: string | null - owner_id?: string | null - query: string - query_class?: string | null - rerank_latency_ms?: number | null - retrieval_strategy?: string | null - rpc_latency_ms?: number | null - selected_chunk_ids?: string[] - selected_count?: number - selected_document_ids?: string[] - text_candidate_count?: number | null - top_hybrid_score?: number | null - top_rrf_score?: number | null - top_similarity?: number | null - top_text_rank?: number | null - total_latency_ms?: number | null - vector_candidate_count?: number | null - } + candidate_count?: number; + created_at?: string; + embedding_cache_hit?: boolean | null; + embedding_field_count?: number | null; + embedding_latency_ms?: number | null; + id?: string; + index_unit_count?: number | null; + is_miss?: boolean; + mean_hybrid_score?: number | null; + memory_card_count?: number | null; + metadata?: Json; + miss_reason?: string | null; + normalized_query?: string | null; + owner_id?: string | null; + query: string; + query_class?: string | null; + rerank_latency_ms?: number | null; + retrieval_strategy?: string | null; + rpc_latency_ms?: number | null; + selected_chunk_ids?: string[]; + selected_count?: number; + selected_document_ids?: string[]; + text_candidate_count?: number | null; + top_hybrid_score?: number | null; + top_rrf_score?: number | null; + top_similarity?: number | null; + top_text_rank?: number | null; + total_latency_ms?: number | null; + vector_candidate_count?: number | null; + }; Update: { - candidate_count?: number - created_at?: string - embedding_cache_hit?: boolean | null - embedding_field_count?: number | null - embedding_latency_ms?: number | null - id?: string - index_unit_count?: number | null - is_miss?: boolean - mean_hybrid_score?: number | null - memory_card_count?: number | null - metadata?: Json - miss_reason?: string | null - normalized_query?: string | null - owner_id?: string | null - query?: string - query_class?: string | null - rerank_latency_ms?: number | null - retrieval_strategy?: string | null - rpc_latency_ms?: number | null - selected_chunk_ids?: string[] - selected_count?: number - selected_document_ids?: string[] - text_candidate_count?: number | null - top_hybrid_score?: number | null - top_rrf_score?: number | null - top_similarity?: number | null - top_text_rank?: number | null - total_latency_ms?: number | null - vector_candidate_count?: number | null - } - Relationships: [] - } + candidate_count?: number; + created_at?: string; + embedding_cache_hit?: boolean | null; + embedding_field_count?: number | null; + embedding_latency_ms?: number | null; + id?: string; + index_unit_count?: number | null; + is_miss?: boolean; + mean_hybrid_score?: number | null; + memory_card_count?: number | null; + metadata?: Json; + miss_reason?: string | null; + normalized_query?: string | null; + owner_id?: string | null; + query?: string; + query_class?: string | null; + rerank_latency_ms?: number | null; + retrieval_strategy?: string | null; + rpc_latency_ms?: number | null; + selected_chunk_ids?: string[]; + selected_count?: number; + selected_document_ids?: string[]; + text_candidate_count?: number | null; + top_hybrid_score?: number | null; + top_rrf_score?: number | null; + top_similarity?: number | null; + top_text_rank?: number | null; + total_latency_ms?: number | null; + vector_candidate_count?: number | null; + }; + Relationships: []; + }; rag_visual_eval_cases: { Row: { - active: boolean - case_name: string - created_at: string - document_id: string | null - expected_image_type: string | null - expected_terms: string[] - expected_unit_types: string[] - id: string - metadata: Json - owner_id: string | null - query: string - updated_at: string - } + active: boolean; + case_name: string; + created_at: string; + document_id: string | null; + expected_image_type: string | null; + expected_terms: string[]; + expected_unit_types: string[]; + id: string; + metadata: Json; + owner_id: string | null; + query: string; + updated_at: string; + }; Insert: { - active?: boolean - case_name: string - created_at?: string - document_id?: string | null - expected_image_type?: string | null - expected_terms?: string[] - expected_unit_types?: string[] - id?: string - metadata?: Json - owner_id?: string | null - query: string - updated_at?: string - } + active?: boolean; + case_name: string; + created_at?: string; + document_id?: string | null; + expected_image_type?: string | null; + expected_terms?: string[]; + expected_unit_types?: string[]; + id?: string; + metadata?: Json; + owner_id?: string | null; + query: string; + updated_at?: string; + }; Update: { - active?: boolean - case_name?: string - created_at?: string - document_id?: string | null - expected_image_type?: string | null - expected_terms?: string[] - expected_unit_types?: string[] - id?: string - metadata?: Json - owner_id?: string | null - query?: string - updated_at?: string - } + active?: boolean; + case_name?: string; + created_at?: string; + document_id?: string | null; + expected_image_type?: string | null; + expected_terms?: string[]; + expected_unit_types?: string[]; + id?: string; + metadata?: Json; + owner_id?: string | null; + query?: string; + updated_at?: string; + }; Relationships: [ { - foreignKeyName: "rag_visual_eval_cases_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "rag_visual_eval_cases_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "rag_visual_eval_cases_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "rag_visual_eval_cases_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; rag_visual_eval_runs: { Row: { - case_id: string - created_at: string - document_id: string | null - hit_payload: Json - id: string - matched_count: number - passed: boolean - run_metadata: Json - top_hit: boolean - } + case_id: string; + created_at: string; + document_id: string | null; + hit_payload: Json; + id: string; + matched_count: number; + passed: boolean; + run_metadata: Json; + top_hit: boolean; + }; Insert: { - case_id: string - created_at?: string - document_id?: string | null - hit_payload?: Json - id?: string - matched_count?: number - passed: boolean - run_metadata?: Json - top_hit: boolean - } + case_id: string; + created_at?: string; + document_id?: string | null; + hit_payload?: Json; + id?: string; + matched_count?: number; + passed: boolean; + run_metadata?: Json; + top_hit: boolean; + }; Update: { - case_id?: string - created_at?: string - document_id?: string | null - hit_payload?: Json - id?: string - matched_count?: number - passed?: boolean - run_metadata?: Json - top_hit?: boolean - } + case_id?: string; + created_at?: string; + document_id?: string | null; + hit_payload?: Json; + id?: string; + matched_count?: number; + passed?: boolean; + run_metadata?: Json; + top_hit?: boolean; + }; Relationships: [ { - foreignKeyName: "rag_visual_eval_runs_case_id_fkey" - columns: ["case_id"] - isOneToOne: false - referencedRelation: "rag_visual_eval_cases" - referencedColumns: ["id"] + foreignKeyName: "rag_visual_eval_runs_case_id_fkey"; + columns: ["case_id"]; + isOneToOne: false; + referencedRelation: "rag_visual_eval_cases"; + referencedColumns: ["id"]; }, { - foreignKeyName: "rag_visual_eval_runs_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "document_strict_gate_status" - referencedColumns: ["document_id"] + foreignKeyName: "rag_visual_eval_runs_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "document_strict_gate_status"; + referencedColumns: ["document_id"]; }, { - foreignKeyName: "rag_visual_eval_runs_document_id_fkey" - columns: ["document_id"] - isOneToOne: false - referencedRelation: "documents" - referencedColumns: ["id"] + foreignKeyName: "rag_visual_eval_runs_document_id_fkey"; + columns: ["document_id"]; + isOneToOne: false; + referencedRelation: "documents"; + referencedColumns: ["id"]; }, - ] - } + ]; + }; storage_cleanup_jobs: { Row: { - attempts: number - completed_at: string | null - created_at: string - document_bucket: string - document_id: string | null - document_paths: string[] - document_title: string | null - id: string - image_bucket: string - image_paths: string[] - last_error: string | null - metadata: Json - owner_id: string | null - status: string - storage_removed: number - updated_at: string - } + attempts: number; + completed_at: string | null; + created_at: string; + document_bucket: string; + document_id: string | null; + document_paths: string[]; + document_title: string | null; + id: string; + image_bucket: string; + image_paths: string[]; + last_error: string | null; + metadata: Json; + owner_id: string | null; + status: string; + storage_removed: number; + updated_at: string; + }; Insert: { - attempts?: number - completed_at?: string | null - created_at?: string - document_bucket?: string - document_id?: string | null - document_paths?: string[] - document_title?: string | null - id?: string - image_bucket?: string - image_paths?: string[] - last_error?: string | null - metadata?: Json - owner_id?: string | null - status?: string - storage_removed?: number - updated_at?: string - } + attempts?: number; + completed_at?: string | null; + created_at?: string; + document_bucket?: string; + document_id?: string | null; + document_paths?: string[]; + document_title?: string | null; + id?: string; + image_bucket?: string; + image_paths?: string[]; + last_error?: string | null; + metadata?: Json; + owner_id?: string | null; + status?: string; + storage_removed?: number; + updated_at?: string; + }; Update: { - attempts?: number - completed_at?: string | null - created_at?: string - document_bucket?: string - document_id?: string | null - document_paths?: string[] - document_title?: string | null - id?: string - image_bucket?: string - image_paths?: string[] - last_error?: string | null - metadata?: Json - owner_id?: string | null - status?: string - storage_removed?: number - updated_at?: string - } - Relationships: [] - } - } + attempts?: number; + completed_at?: string | null; + created_at?: string; + document_bucket?: string; + document_id?: string | null; + document_paths?: string[]; + document_title?: string | null; + id?: string; + image_bucket?: string; + image_paths?: string[]; + last_error?: string | null; + metadata?: Json; + owner_id?: string | null; + status?: string; + storage_removed?: number; + updated_at?: string; + }; + Relationships: []; + }; + }; Views: { document_strict_gate_status: { Row: { - counts: Json | null - document_id: string | null - document_status: string | null - document_updated_at: string | null - enrichment_status: string | null - gate_passed: boolean | null - generated_labels: number | null - index_units: number | null - indexing_v3_agent_status: string | null - memory_cards: number | null - missing: string[] | null - owner_id: string | null - presence: Json | null - quality_extraction_quality: string | null - quality_score: number | null - sections: number | null - summary_embedding: boolean | null - title_embedding: boolean | null - } - Relationships: [] - } - } + counts: Json | null; + document_id: string | null; + document_status: string | null; + document_updated_at: string | null; + enrichment_status: string | null; + gate_passed: boolean | null; + generated_labels: number | null; + index_units: number | null; + indexing_v3_agent_status: string | null; + memory_cards: number | null; + missing: string[] | null; + owner_id: string | null; + presence: Json | null; + quality_extraction_quality: string | null; + quality_score: number | null; + sections: number | null; + summary_embedding: boolean | null; + title_embedding: boolean | null; + }; + Relationships: []; + }; + }; Functions: { - analyze_rag_tables: { Args: never; Returns: undefined } + analyze_rag_tables: { Args: never; Returns: undefined }; consume_api_subject_rate_limit: { Args: { - p_subject_key: string - p_bucket: string - p_limit: number - p_window_seconds: number - } + p_subject_key: string; + p_bucket: string; + p_limit: number; + p_window_seconds: number; + }; Returns: { - limited: boolean - limit_value: number - remaining: number - retry_after_seconds: number - reset_at: string - }[] - } + limited: boolean; + limit_value: number; + remaining: number; + retry_after_seconds: number; + reset_at: string; + }[]; + }; chunk_image_metadata: { - Args: { chunk_image_ids: string[] } - Returns: Json - } + Args: { chunk_image_ids: string[] }; + Returns: Json; + }; claim_indexing_v3_agent_jobs: { Args: { - p_claim_limit?: number - p_stale_after_minutes?: number - p_worker_id: string - } + p_claim_limit?: number; + p_stale_after_minutes?: number; + p_worker_id: string; + }; Returns: { - attempt_count: number - batch_id: string - document_id: string - documents: Json - error_message: string - id: string - locked_at: string - locked_by: string - max_attempts: number - progress: number - stage: string - status: string - }[] - } + attempt_count: number; + batch_id: string; + document_id: string; + documents: Json; + error_message: string; + id: string; + locked_at: string; + locked_by: string; + max_attempts: number; + progress: number; + stage: string; + status: string; + }[]; + }; claim_ingestion_jobs: { Args: { - p_claim_limit?: number - p_stale_after_minutes?: number - p_worker_id: string - } + p_claim_limit?: number; + p_stale_after_minutes?: number; + p_worker_id: string; + }; Returns: { - attempt_count: number - batch_id: string - document_id: string - documents: Json - error_message: string - id: string - locked_at: string - locked_by: string - max_attempts: number - progress: number - stage: string - status: string - }[] - } + attempt_count: number; + batch_id: string; + document_id: string; + documents: Json; + error_message: string; + id: string; + locked_at: string; + locked_by: string; + max_attempts: number; + progress: number; + stage: string; + status: string; + }[]; + }; cleanup_abandoned_document_index_generations: { - Args: { p_document_id?: string | null; p_dry_run?: boolean; p_limit?: number } - Returns: Json - } + Args: { p_document_id?: string | null; p_dry_run?: boolean; p_limit?: number }; + Returns: Json; + }; commit_document_index_generation: { Args: { - p_chunk_count?: number - p_document_id: string - p_image_count?: number - p_index_generation_id: string - p_metadata?: Json - p_page_count?: number - p_pages?: Json - p_quality?: Json - p_status?: string - } - Returns: Json - } + p_chunk_count?: number; + p_document_id: string; + p_image_count?: number; + p_index_generation_id: string; + p_metadata?: Json; + p_page_count?: number; + p_pages?: Json; + p_quality?: Json; + p_status?: string; + }; + Returns: Json; + }; complete_ingestion_job: { Args: { - p_batch_id?: string | null - p_document_id: string - p_job_id: string - p_stage?: string - } - Returns: Json - } + p_batch_id?: string | null; + p_document_id: string; + p_job_id: string; + p_stage?: string; + }; + Returns: Json; + }; complete_strict_enrichment_job: { Args: { - p_agent_version?: string - p_document_id: string - p_job_id?: string - p_stage?: string - p_visual_indexing_version?: string - } + p_agent_version?: string; + p_document_id: string; + p_job_id?: string; + p_stage?: string; + p_visual_indexing_version?: string; + }; Returns: { - completed_job_ids: string[] - counts: Json - document_id: string - gate_passed: boolean - missing: string[] - ok: boolean - presence: Json - status: string - }[] - } + completed_job_ids: string[]; + counts: Json; + document_id: string; + gate_passed: boolean; + missing: string[]; + ok: boolean; + presence: Json; + status: string; + }[]; + }; consume_api_rate_limit: { Args: { - p_bucket: string - p_limit: number - p_owner_id: string - p_window_seconds: number - } + p_bucket: string; + p_limit: number; + p_owner_id: string; + p_window_seconds: number; + }; Returns: { - limit_value: number - limited: boolean - remaining: number - reset_at: string - retry_after_seconds: number - }[] - } + limit_value: number; + limited: boolean; + remaining: number; + reset_at: string; + retry_after_seconds: number; + }[]; + }; correct_clinical_query_terms: { - Args: { input_query: string; min_sim?: number } - Returns: string - } - detect_legacy_ivfflat_indexes: { Args: never; Returns: string[] } + Args: { input_query: string; min_sim?: number }; + Returns: string; + }; + detect_legacy_ivfflat_indexes: { Args: never; Returns: string[] }; document_label_metadata: { - Args: { p_document_id: string } - Returns: Json - } + Args: { p_document_id: string }; + Returns: Json; + }; document_summary_text: { - Args: { p_document_id: string } - Returns: string - } + Args: { p_document_id: string }; + Returns: string; + }; explain_retrieval_rpc: { Args: { - p_analyze?: boolean - p_document_filters?: string[] | null - p_match_count?: number - p_owner_filter?: string | null - p_query_text: string - p_rpc: string - } - Returns: Json - } + p_analyze?: boolean; + p_document_filters?: string[] | null; + p_match_count?: number; + p_owner_filter?: string | null; + p_query_text: string; + p_rpc: string; + }; + Returns: Json; + }; fail_or_retry_ingestion_job: { Args: { - p_batch_id?: string | null - p_document_id: string - p_document_status?: string - p_error_message?: string - p_job_id: string - p_next_run_at?: string | null - p_retry?: boolean - p_stage?: string - } - Returns: Json - } + p_batch_id?: string | null; + p_document_id: string; + p_document_status?: string; + p_error_message?: string; + p_job_id: string; + p_next_run_at?: string | null; + p_retry?: boolean; + p_stage?: string; + }; + Returns: Json; + }; get_related_document_metadata: { - Args: { document_ids: string[]; owner_filter?: string | null } + Args: { document_ids: string[]; owner_filter?: string | null }; Returns: { - document_id: string - labels: Json - summary: string - }[] - } + document_id: string; + labels: Json; + summary: string; + }[]; + }; get_visual_evidence_cards: { - Args: { p_document_id: string; p_limit?: number } + Args: { p_document_id: string; p_limit?: number }; Returns: { - image_caption: string - image_storage_path: string - image_type: string - page_number: number - source_image_id: string - unit_content: string - unit_id: string - unit_metadata: Json - unit_quality_score: number - unit_title: string - unit_type: string - }[] - } - invoke_indexing_v3_agent: { Args: { p_limit?: number }; Returns: number } - invoke_ingestion_worker: { Args: { p_limit?: number }; Returns: number } + image_caption: string; + image_storage_path: string; + image_type: string; + page_number: number; + source_image_id: string; + unit_content: string; + unit_id: string; + unit_metadata: Json; + unit_quality_score: number; + unit_title: string; + unit_type: string; + }[]; + }; + invoke_indexing_v3_agent: { Args: { p_limit?: number }; Returns: number }; + invoke_ingestion_worker: { Args: { p_limit?: number }; Returns: number }; is_committed_artifact_generation: { - Args: { artifact_metadata: Json; document_metadata: Json } - Returns: boolean - } + Args: { artifact_metadata: Json; document_metadata: Json }; + Returns: boolean; + }; is_committed_document_generation: { - Args: { document_metadata: Json; row_generation: string } - Returns: boolean - } + Args: { document_metadata: Json; row_generation: string }; + Returns: boolean; + }; match_document_chunks: { Args: { - document_filter?: string | null - match_count?: number - min_similarity?: number - owner_filter?: string | null - query_embedding: Vector - } + document_filter?: string | null; + match_count?: number; + min_similarity?: number; + owner_filter?: string | null; + query_embedding: Vector; + }; Returns: { - chunk_index: number - content: string - document_id: string - document_labels: Json - document_summary: string - file_name: string - id: string - image_ids: string[] - images: Json - page_number: number - retrieval_synopsis: string - section_heading: string - similarity: number - source_metadata: Json - title: string - }[] - } + chunk_index: number; + content: string; + document_id: string; + document_labels: Json; + document_summary: string; + file_name: string; + id: string; + image_ids: string[]; + images: Json; + page_number: number; + retrieval_synopsis: string; + section_heading: string; + similarity: number; + source_metadata: Json; + title: string; + }[]; + }; match_document_chunks_hybrid: { Args: { - document_filters?: string[] | null - match_count?: number - min_similarity?: number - owner_filter?: string | null - query_embedding: Vector - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + min_similarity?: number; + owner_filter?: string | null; + query_embedding: Vector; + query_text: string; + }; Returns: { - chunk_index: number - content: string - document_id: string - file_name: string - hybrid_score: number - id: string - image_ids: string[] - images: Json - page_number: number - retrieval_synopsis: string - rrf_score: number - section_heading: string - similarity: number - source_metadata: Json - text_rank: number - title: string - }[] - } + chunk_index: number; + content: string; + document_id: string; + file_name: string; + hybrid_score: number; + id: string; + image_ids: string[]; + images: Json; + page_number: number; + retrieval_synopsis: string; + rrf_score: number; + section_heading: string; + similarity: number; + source_metadata: Json; + text_rank: number; + title: string; + }[]; + }; match_document_chunks_text: { Args: { - document_filters?: string[] | null - match_count?: number - owner_filter?: string | null - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + owner_filter?: string | null; + query_text: string; + }; Returns: { - chunk_index: number - content: string - document_id: string - document_labels: Json - document_summary: string - file_name: string - hybrid_score: number - id: string - image_ids: string[] - images: Json - page_number: number - retrieval_synopsis: string - section_heading: string - similarity: number - source_metadata: Json - text_rank: number - title: string - }[] - } + chunk_index: number; + content: string; + document_id: string; + document_labels: Json; + document_summary: string; + file_name: string; + hybrid_score: number; + id: string; + image_ids: string[]; + images: Json; + page_number: number; + retrieval_synopsis: string; + section_heading: string; + similarity: number; + source_metadata: Json; + text_rank: number; + title: string; + }[]; + }; match_document_embedding_fields_hybrid: { Args: { - document_filters?: string[] | null - match_count?: number - min_similarity?: number - owner_filter?: string | null - query_embedding: Vector - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + min_similarity?: number; + owner_filter?: string | null; + query_embedding: Vector; + query_text: string; + }; Returns: { - content: string - document_id: string - field_type: string - hybrid_score: number - id: string - similarity: number - source_chunk_id: string - text_rank: number - }[] - } + content: string; + document_id: string; + field_type: string; + hybrid_score: number; + id: string; + similarity: number; + source_chunk_id: string; + text_rank: number; + }[]; + }; match_document_embedding_fields_text: { Args: { - document_filters?: string[] | null - match_count?: number - min_text_rank?: number - owner_filter?: string | null - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + min_text_rank?: number; + owner_filter?: string | null; + query_text: string; + }; Returns: { - content: string - document_id: string - field_type: string - id: string - source_chunk_id: string - text_rank: number - }[] - } + content: string; + document_id: string; + field_type: string; + id: string; + source_chunk_id: string; + text_rank: number; + }[]; + }; match_document_index_units_hybrid: { Args: { - document_filters?: string[] | null - match_count?: number - min_similarity?: number - owner_filter?: string | null - query_embedding: Vector - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + min_similarity?: number; + owner_filter?: string | null; + query_embedding: Vector; + query_text: string; + }; Returns: { - content: string - document_id: string - extraction_mode: string - heading_path: string[] - hybrid_score: number - id: string - metadata: Json - normalized_terms: string[] - page_end: number - page_start: number - quality_score: number - similarity: number - source_chunk_id: string - source_image_id: string - source_span: Json - text_rank: number - title: string - unit_type: string - }[] - } + content: string; + document_id: string; + extraction_mode: string; + heading_path: string[]; + hybrid_score: number; + id: string; + metadata: Json; + normalized_terms: string[]; + page_end: number; + page_start: number; + quality_score: number; + similarity: number; + source_chunk_id: string; + source_image_id: string; + source_span: Json; + text_rank: number; + title: string; + unit_type: string; + }[]; + }; match_document_lookup_chunks_text: { Args: { - document_filters: string[] | null - match_count?: number - owner_filter?: string | null - query_text: string - } + document_filters: string[] | null; + match_count?: number; + owner_filter?: string | null; + query_text: string; + }; Returns: { - anchor_id: string - chunk_index: number - content: string - document_id: string - heading_level: number - id: string - image_ids: string[] - page_number: number - parent_heading: string - retrieval_synopsis: string - section_heading: string - section_path: string[] - text_rank: number - }[] - } + anchor_id: string; + chunk_index: number; + content: string; + document_id: string; + heading_level: number; + id: string; + image_ids: string[]; + page_number: number; + parent_heading: string; + retrieval_synopsis: string; + section_heading: string; + section_path: string[]; + text_rank: number; + }[]; + }; match_document_memory_cards_hybrid: { Args: { - document_filters?: string[] | null - match_count?: number - min_similarity?: number - owner_filter?: string | null - query_embedding: Vector - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + min_similarity?: number; + owner_filter?: string | null; + query_embedding: Vector; + query_text: string; + }; Returns: { - card_type: string - confidence: number - content: string - document_id: string - hybrid_score: number - id: string - metadata: Json - normalized_terms: string[] - owner_id: string - page_number: number - rrf_score: number - section_id: string - similarity: number - source_chunk_ids: string[] - source_image_ids: string[] - text_rank: number - title: string - }[] - } + card_type: string; + confidence: number; + content: string; + document_id: string; + hybrid_score: number; + id: string; + metadata: Json; + normalized_terms: string[]; + owner_id: string; + page_number: number; + rrf_score: number; + section_id: string; + similarity: number; + source_chunk_ids: string[]; + source_image_ids: string[]; + text_rank: number; + title: string; + }[]; + }; match_document_memory_cards_hybrid_v2: { Args: { - document_filters?: string[] | null - match_count?: number - min_similarity?: number - owner_filter?: string | null - query_embedding: Vector - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + min_similarity?: number; + owner_filter?: string | null; + query_embedding: Vector; + query_text: string; + }; Returns: { - card_type: string - confidence: number - content: string - document_id: string - hybrid_score: number - id: string - metadata: Json - normalized_terms: string[] - owner_id: string - page_number: number - rrf_score: number - section_id: string - similarity: number - source_chunk_ids: string[] - source_image_ids: string[] - text_rank: number - title: string - }[] - } + card_type: string; + confidence: number; + content: string; + document_id: string; + hybrid_score: number; + id: string; + metadata: Json; + normalized_terms: string[]; + owner_id: string; + page_number: number; + rrf_score: number; + section_id: string; + similarity: number; + source_chunk_ids: string[]; + source_image_ids: string[]; + text_rank: number; + title: string; + }[]; + }; match_document_table_facts_text: { Args: { - document_filters?: string[] | null - match_count?: number - owner_filter?: string | null - query_text: string - } + document_filters?: string[] | null; + match_count?: number; + owner_filter?: string | null; + query_text: string; + }; Returns: { - action: string - clinical_parameter: string - document_id: string - id: string - match_reason: string - page_number: number - row_label: string - source_chunk_id: string - source_image_id: string - table_title: string - text_rank: number - threshold_value: string - }[] - } + action: string; + clinical_parameter: string; + document_id: string; + id: string; + match_reason: string; + page_number: number; + row_label: string; + source_chunk_id: string; + source_image_id: string; + table_title: string; + text_rank: number; + threshold_value: string; + }[]; + }; match_documents_for_query: { Args: { - match_count?: number - owner_filter?: string | null - query_text: string - } + match_count?: number; + owner_filter?: string | null; + query_text: string; + }; Returns: { - chunk_count: number - file_name: string - id: string - image_count: number - match_reason: string - metadata: Json - owner_id: string - page_count: number - status: string - text_rank: number - title: string - }[] - } + chunk_count: number; + file_name: string; + id: string; + image_count: number; + match_reason: string; + metadata: Json; + owner_id: string; + page_count: number; + status: string; + text_rank: number; + title: string; + }[]; + }; purge_expired_rag_queries: { - Args: { p_retention_days?: number } - Returns: number - } + Args: { p_retention_days?: number }; + Returns: number; + }; refresh_import_batch_status: { - Args: { p_batch_id: string } - Returns: Json - } + Args: { p_batch_id: string }; + Returns: Json; + }; repair_enrichment_quality_batch: { - Args: { p_limit?: number } - Returns: Json - } + Args: { p_limit?: number }; + Returns: Json; + }; repair_strict_enrichment_gate_batch: { - Args: { p_limit?: number } + Args: { p_limit?: number }; Returns: { - counts: Json - document_id: string - missing: string[] - presence: Json - repaired: string[] - status: string - }[] - } + counts: Json; + document_id: string; + missing: string[]; + presence: Json; + repaired: string[]; + status: string; + }[]; + }; reset_document_index: { - Args: { p_document_id: string } - Returns: undefined - } - run_all_visual_eval_cases: { Args: { p_limit?: number }; Returns: Json } + Args: { p_document_id: string }; + Returns: undefined; + }; + run_all_visual_eval_cases: { Args: { p_limit?: number }; Returns: Json }; run_visual_eval_case: { - Args: { p_case_id: string; p_limit?: number } - Returns: Json - } + Args: { p_case_id: string; p_limit?: number }; + Returns: Json; + }; search_document_chunks: { Args: { - match_count?: number - p_document_id: string - p_owner_id?: string - p_query: string - } + match_count?: number; + p_document_id: string; + p_owner_id?: string; + p_query: string; + }; Returns: { - chunk_index: number - content: string - id: string - image_ids: string[] - page_number: number - section_heading: string - text_rank: number - trigram_score: number - }[] - } - search_schema_health: { Args: never; Returns: Json } + chunk_index: number; + content: string; + id: string; + image_ids: string[]; + page_number: number; + section_heading: string; + text_rank: number; + trigram_score: number; + }[]; + }; + search_schema_health: { Args: never; Returns: Json }; stamp_document_deep_memory_version: { - Args: { p_document_id: string; p_version: string } - Returns: undefined - } - } + Args: { p_document_id: string; p_version: string }; + Returns: undefined; + }; + }; Enums: { - [_ in never]: never - } + [_ in never]: never; + }; CompositeTypes: { - [_ in never]: never - } - } -} + [_ in never]: never; + }; + }; +}; -type DatabaseWithoutInternals = Omit +type DatabaseWithoutInternals = Omit; -type DefaultSchema = DatabaseWithoutInternals[Extract] +type DefaultSchema = DatabaseWithoutInternals[Extract]; export type Tables< DefaultSchemaTableNameOrOptions extends - | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, + TableName extends (DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R + Row: infer R; } ? R : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R; } ? R : never - : never + : never; export type TablesInsert< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, + TableName extends (DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I + Insert: infer I; } ? I : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I + Insert: infer I; } ? I : never - : never + : never; export type TablesUpdate< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, + TableName extends (DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U + Update: infer U; } ? U : never : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U + Update: infer U; } ? U : never - : never + : never; export type Enums< - DefaultSchemaEnumNameOrOptions extends - | keyof DefaultSchema["Enums"] - | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, + EnumName extends (DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, + : never) = never, > = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never + : never; export type CompositeTypes< PublicCompositeTypeNameOrOptions extends - | keyof DefaultSchema["CompositeTypes"] - | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals; } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, + : never) = never, > = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals + schema: keyof DatabaseWithoutInternals; } ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never + : never; export const Constants = { graphql_public: { @@ -2636,4 +2626,4 @@ export const Constants = { public: { Enums: {}, }, -} as const +} as const; diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts index 0cf8a09da..79599d5af 100644 --- a/src/lib/use-registry-records.ts +++ b/src/lib/use-registry-records.ts @@ -82,8 +82,12 @@ export function useRegistryRecords( // effect retries with a real header; never expire the session from an // auth-loading 401. Demo/local API responses can still resolve fast. if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState(recordsState("unauthorized", kind)); + if (authStatus === "authenticated") { + markSessionExpired(); + setState(recordsState("unauthorized", kind)); + return; + } + setState(recordsState("error", kind)); return; } if (!response.ok) { @@ -140,8 +144,12 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis if (!active) return; if (response.status === 401) { if (authStatus === "loading") return; - if (authStatus === "authenticated") markSessionExpired(); - setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null }); + if (authStatus === "authenticated") { + markSessionExpired(); + setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null }); + return; + } + setState({ status: "error", record: null, linkedDocuments: [], demoMode: false, governance: null }); return; } if (response.status === 404) { diff --git a/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql new file mode 100644 index 000000000..473fdb90c --- /dev/null +++ b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql @@ -0,0 +1,72 @@ +-- Tighten search_document_chunks owner scoping so null p_owner_id only matches public +-- documents (owner_id IS NULL) and authenticated callers can search both owned and public +-- documents without matching other owners' private rows. + +create or replace function public.search_document_chunks( + p_document_id uuid, + p_query text, + match_count integer default 20, + p_owner_id uuid default null +) +returns table ( + id uuid, + page_number integer, + chunk_index integer, + section_heading text, + content text, + image_ids uuid[], + text_rank real, + trigram_score real +) +language sql +stable +set search_path = public, extensions, pg_temp +as $$ + with normalized as ( + select + websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv, + lower(trim(coalesce(p_query, ''))) as query_text + ), + tokens as ( + select distinct token + from normalized, + lateral regexp_split_to_table(normalized.query_text, '\s+') as token + where length(token) >= 3 + ) + select + c.id, + c.page_number, + c.chunk_index, + c.section_heading, + c.content, + c.image_ids, + ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score + from public.document_chunks c + join public.documents d on d.id = c.document_id + cross join normalized + where c.document_id = p_document_id + and d.status = 'indexed' + and ( + (p_owner_id is null and d.owner_id is null) + or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id)) + ) + and ( + c.search_tsv @@ normalized.query_tsv + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text + or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%' + or exists ( + select 1 + from tokens t + where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%' + or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token + ) + ) + order by + ts_rank_cd(c.search_tsv, normalized.query_tsv) desc, + similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc, + c.chunk_index asc + limit least(greatest(match_count, 1), 80); +$$; + +grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role; diff --git a/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql new file mode 100644 index 000000000..21e0c281d --- /dev/null +++ b/supabase/migrations/20260705180000_reconcile_search_health_indexes.sql @@ -0,0 +1,202 @@ +-- Reconcile live index drift reported by search_schema_health() on 2026-07-05. +-- Creates canonical retrieval-support indexes that are missing on live (or only +-- present under legacy names) and teaches search_schema_health() to accept +-- verified functional equivalents so replays do not false-fail mid-rollout. + +set search_path = public, extensions, pg_catalog; + +create index if not exists documents_title_trgm_idx + on public.documents using gin (lower(coalesce(title, '') || ' ' || coalesce(file_name, '')) gin_trgm_ops); + +create index if not exists document_chunks_content_trgm_idx + on public.document_chunks using gin (lower(coalesce(section_heading, '') || ' ' || coalesce(content, '')) gin_trgm_ops); + +create index if not exists document_labels_label_trgm_idx + on public.document_labels using gin (lower(label) gin_trgm_ops); + +create index if not exists document_summaries_summary_trgm_idx + on public.document_summaries using gin (lower(summary) gin_trgm_ops); + +create index if not exists document_table_facts_owner_document_page_idx + on public.document_table_facts(owner_id, document_id, page_number); + +create index if not exists document_index_units_owner_chunk_type_idx + on public.document_index_units(owner_id, source_chunk_id, unit_type) + where source_chunk_id is not null; + +create index if not exists document_pages_document_idx + on public.document_pages(document_id, page_number); + +create index if not exists document_sections_document_idx + on public.document_sections(document_id, section_index); + +create index if not exists rag_retrieval_logs_owner_created_idx + on public.rag_retrieval_logs(owner_id, created_at desc); + +create index if not exists rag_retrieval_logs_miss_idx + on public.rag_retrieval_logs(is_miss, created_at desc) + where is_miss = true; + +create or replace function public.search_schema_health() +returns jsonb +language plpgsql +stable +security definer +set search_path = public, extensions, pg_catalog, pg_temp +as $$ +declare + missing text[] := array[]::text[]; + vector_type_oid oid; + vector_schema text; + index_name text; + legacy_ivfflat_indexes text[]; + zero_vec extensions.vector(1536); + probe_text text := 'schema health probe zzznomatch'; + hybrid_rpcs text[] := array[ + 'match_document_chunks_hybrid', + 'match_document_index_units_hybrid', + 'match_document_embedding_fields_hybrid', + 'match_document_memory_cards_hybrid' + ]; + rpc_name text; + required_indexes constant text[] := array[ + 'documents_title_trgm_idx', + 'document_chunks_content_trgm_idx', + 'document_labels_label_trgm_idx', + 'document_summaries_summary_trgm_idx', + 'document_chunks_embedding_hnsw_idx', + 'document_embedding_fields_embedding_hnsw_idx', + 'document_memory_cards_embedding_hnsw_idx', + 'documents_indexed_owner_title_idx', + 'document_table_facts_owner_document_page_idx', + 'document_embedding_fields_owner_chunk_idx', + 'document_index_units_owner_chunk_type_idx', + 'document_table_facts_source_image_idx', + 'document_pages_document_idx', + 'document_sections_document_idx', + 'document_chunks_document_idx', + 'document_memory_cards_document_idx', + 'document_embedding_fields_document_idx', + 'document_table_facts_document_idx', + 'document_index_units_document_idx', + 'rag_retrieval_logs_owner_created_idx', + 'rag_retrieval_logs_miss_idx', + 'rag_retrieval_logs_strategy_idx' + ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); +begin + select t.oid, n.nspname + into vector_type_oid, vector_schema + from pg_type t + join pg_namespace n on n.oid = t.typnamespace + where t.typname = 'vector' + and n.nspname = 'extensions' + limit 1; + + if vector_type_oid is null then + missing := array_append(missing, 'extensions.vector_type'); + end if; + + if to_regprocedure('public.match_document_chunks(extensions.vector, integer, double precision, uuid, uuid)') is null then + missing := array_append(missing, 'match_document_chunks.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_chunks_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_lookup_chunks_text(text, uuid[], integer, uuid)') is null then + missing := array_append(missing, 'match_document_lookup_chunks_text.signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_memory_cards_hybrid_v2(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_memory_cards_hybrid_v2.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_index_units_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_index_units_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_document_embedding_fields_hybrid(extensions.vector, text, integer, double precision, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_embedding_fields_hybrid.extensions_vector_signature'); + end if; + if to_regprocedure('public.match_documents_for_query(text, integer, uuid)') is null then + missing := array_append(missing, 'match_documents_for_query.signature'); + end if; + if to_regprocedure('public.match_document_table_facts_text(text, integer, uuid[], uuid)') is null then + missing := array_append(missing, 'match_document_table_facts_text.signature'); + end if; + if to_regprocedure('public.explain_retrieval_rpc(text, text, integer, uuid, uuid[], boolean)') is null then + missing := array_append(missing, 'explain_retrieval_rpc.signature'); + end if; + if to_regclass('public.rag_retrieval_logs') is null then + missing := array_append(missing, 'rag_retrieval_logs.table'); + end if; + + foreach index_name in array required_indexes loop + if not exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relname = index_name + and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) + ) then + missing := array_append(missing, index_name); + end if; + end loop; + + if vector_type_oid is not null then + zero_vec := (select ('[' || string_agg('0', ',') || ']') from generate_series(1, 1536))::extensions.vector(1536); + foreach rpc_name in array hybrid_rpcs loop + begin + execute format( + 'select 1 from public.%I($1, $2, 1, 0.1, null::uuid[], null::uuid) limit 1', + rpc_name + ) using zero_vec, probe_text; + exception + when undefined_function then + missing := array_append(missing, rpc_name || '.execution_signature'); + when others then + missing := array_append(missing, rpc_name || '.execution:' || SQLSTATE); + end; + end loop; + end if; + + select public.detect_legacy_ivfflat_indexes() into legacy_ivfflat_indexes; + + return jsonb_build_object( + 'ok', cardinality(missing) = 0, + 'missing', missing, + 'vector_extension_schema', vector_schema, + 'legacy_ivfflat_indexes', coalesce(legacy_ivfflat_indexes, array[]::text[]), + 'deferred_hnsw_indexes', array[]::text[], + 'checked_at', now() + ); +end; +$$; + +revoke execute on function public.search_schema_health() from public, anon, authenticated; +grant execute on function public.search_schema_health() to service_role; diff --git a/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql new file mode 100644 index 000000000..76c9a5560 --- /dev/null +++ b/supabase/migrations/20260705220000_promote_locally_reviewed_documents_public.sql @@ -0,0 +1,65 @@ +-- Promote locally reviewed indexed documents to the public corpus (owner_id IS NULL) +-- so anonymous callers can list, preview, search, and use them in RAG. + +begin; + +create temporary table promoted_public_documents on commit drop as +with promoted as ( + update public.documents d + set + owner_id = null, + metadata = jsonb_set( + coalesce(d.metadata, '{}'::jsonb), + '{public_corpus}', + 'true'::jsonb, + true + ), + updated_at = now() + where d.status = 'indexed' + and d.owner_id is not null + and coalesce(d.metadata->>'clinical_validation_status', 'unverified') in ('locally_reviewed', 'approved') + returning d.id, d.owner_id as previous_owner_id +) +select id, previous_owner_id from promoted; + +update public.document_labels dl +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dl.document_id = pd.id; + +update public.document_summaries ds +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where ds.document_id = pd.id; + +update public.document_sections ds +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where ds.document_id = pd.id; + +update public.document_memory_cards dmc +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dmc.document_id = pd.id; + +update public.document_table_facts dtf +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where dtf.document_id = pd.id; + +update public.document_embedding_fields def +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where def.document_id = pd.id; + +update public.document_index_quality diq +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where diq.document_id = pd.id; + +update public.document_index_units diu +set owner_id = null, updated_at = now() +from promoted_public_documents pd +where diu.document_id = pd.id; + +commit; diff --git a/supabase/schema.sql b/supabase/schema.sql index 63657b132..16ce2230c 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -2396,6 +2396,15 @@ declare 'rag_retrieval_logs_miss_idx', 'rag_retrieval_logs_strategy_idx' ]; + -- Verified live equivalents: same table/column intent, different migration-era name. + index_aliases constant jsonb := jsonb_build_object( + 'documents_title_trgm_idx', jsonb_build_array('documents_title_search_tsv_idx', 'documents_title_search_idx'), + 'document_chunks_content_trgm_idx', jsonb_build_array('document_chunks_search_tsv_idx', 'document_chunks_search_idx'), + 'document_table_facts_owner_document_page_idx', jsonb_build_array('document_table_facts_owner_idx'), + 'document_pages_document_idx', jsonb_build_array('document_pages_document_id_page_number_key'), + 'document_sections_document_idx', jsonb_build_array('document_sections_document_id_idx'), + 'rag_retrieval_logs_owner_created_idx', jsonb_build_array('rag_retrieval_logs_owner_id_idx') + ); begin select t.oid, n.nspname into vector_type_oid, vector_schema @@ -2454,6 +2463,19 @@ begin where ns.nspname = 'public' and c.relname = index_name and c.relkind = 'i' + ) + and not ( + index_aliases ? index_name + and exists ( + select 1 + from pg_class c + join pg_namespace ns on ns.oid = c.relnamespace + where ns.nspname = 'public' + and c.relkind = 'i' + and c.relname in ( + select jsonb_array_elements_text(index_aliases -> index_name) + ) + ) ) then missing := array_append(missing, index_name); end if; diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts new file mode 100644 index 000000000..c25bb36c5 --- /dev/null +++ b/tests/api-rate-limit-fallback.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("allowRateLimitInMemoryFallbackOnUnavailable", () => { + it("enables fallback for production deployments", async () => { + vi.stubEnv("NODE_ENV", "production"); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); + + it("enables fallback for local no-auth development", async () => { + vi.stubEnv("NODE_ENV", "development"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => true, + })); + const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit"); + expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); + }); +}); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 4bdbf8834..a23c87c0e 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -147,7 +147,23 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([])) { calls.push(call); return new QueryBuilder(call, resolve); }), - rpc: vi.fn(async () => ok([])), + rpc: vi.fn(async (name: string) => { + if (name === "consume_api_subject_rate_limit" || name === "consume_api_rate_limit") { + return { + data: [ + { + limited: false, + limit_value: 100, + remaining: 99, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + }; + } + return ok([]); + }), storage: { from: storageFrom }, storageMocks: { upload, remove, createSignedUrl, storageFrom }, }; @@ -180,6 +196,13 @@ function mockRuntime(client: ReturnType) { vi.doMock("@/lib/supabase/auth", () => ({ AuthenticationError: class AuthenticationError extends Error {}, requireAuthenticatedUser, + getOptionalAuthenticatedUser: vi.fn(async (request: Request) => { + const authorization = request.headers.get("authorization") ?? ""; + if (/^Bearer\s+\S+/i.test(authorization)) return { id: userId }; + const cookieHeader = request.headers.get("cookie") ?? ""; + if (cookieHeader.includes("sb-")) return { id: userId }; + return null; + }), unauthorizedResponse: () => new Response(JSON.stringify({ error: "Authentication required." }), { status: 401, diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index d4888a935..fa4c7e48c 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -9,12 +9,13 @@ import { sourceWarningsForRagQualityAnswer, type RagQualityResult, } from "../scripts/eval-quality"; -import type { GoldenRetrievalResult } from "../scripts/eval-retrieval"; +import { evaluateGoldenRetrievalCase, type GoldenRetrievalResult } from "../scripts/eval-retrieval"; function retrievalResult(overrides: Partial = {}): GoldenRetrievalResult { const base: GoldenRetrievalResult = { id: "retrieval-1", query: "What ANC threshold should withhold clozapine?", + forceEmbedding: false, expectedQueryClass: "table_threshold", actualQueryClass: "table_threshold", expectedDocumentSubstrings: ["clozapine.pdf"], @@ -166,6 +167,8 @@ describe("eval quality reporting", () => { structured_threshold_text_match: 1, }); expect(report.retrieval.summary.second_stage_rerank_rate).toBe(0.5); + expect(report.retrieval.summary.force_embedding_case_count).toBe(0); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(0); expect(report.rag.summary.unsupported_correct_rate).toBe(0); expect(report.rag.summary.numeric_grounding_failure_rate).toBeCloseTo(0.3333, 4); expect(report.rag.summary.source_governance_danger_failure_rate).toBeCloseTo(0.3333, 4); @@ -180,6 +183,53 @@ describe("eval quality reporting", () => { ); }); + it("fails forced-embedding retrieval cases that return from cache, coverage, or lexical paths", () => { + const result = evaluateGoldenRetrievalCase({ + testCase: { + id: "vector-regression", + query: "How is panic disorder managed?", + expectedQueryClass: "broad_summary", + expectedDocumentSubstrings: [], + expectedContentTerms: [], + topK: 8, + expectTableEvidence: false, + forceEmbedding: true, + }, + results: [], + telemetry: { + query_class: "broad_summary", + retrieval_strategy: "text_fast_path", + embedding_skipped: true, + embedding_skip_reason: "strong_document_text_score", + text_fast_path_reason: "strong_document_text_score", + text_candidate_budget: 32, + text_candidate_count: 5, + vector_candidate_count: 0, + retrieval_layer_counts: { text_candidates: 5 }, + coverage_gate_decision: "accepted", + coverage_gate_reason: "document_title_evidence_gate", + second_stage_rerank_used: false, + }, + latencyMs: 10, + }); + + expect(result.forceEmbedding).toBe(true); + expect(result.failures).toEqual( + expect.arrayContaining([ + "forceEmbedding expected embedding to run", + "forceEmbedding returned lexical strategy text_fast_path", + "forceEmbedding returned coverage gate", + "forceEmbedding found no vector-layer candidates", + ]), + ); + const report = buildEvalQualityReport({ retrievalResults: [result], ragResults: [] }); + expect(report.retrieval.summary.force_embedding_case_count).toBe(1); + expect(report.retrieval.summary.force_embedding_failure_count).toBe(1); + expect(report.blocking_threshold_failures).toEqual( + expect.arrayContaining([expect.stringContaining("retrieval force_embedding_failure_count 1 above 0")]), + ); + }); + it("derives source governance warnings for direct RAG answers without precomputed warnings", () => { const warnings = sourceWarningsForRagQualityAnswer({ sourceGovernanceWarnings: undefined, diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 45b5b1e61..d7ae5da91 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -276,7 +276,14 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { function mockRuntime( client: ReturnType, ragMock?: Record, - options: { localNoAuth?: boolean; localOwnerEmail?: string; providerMode?: string; openAiKey?: string } = {}, + options: { + localNoAuth?: boolean; + localOwnerEmail?: string; + providerMode?: string; + openAiKey?: string; + publicUploadsEnabled?: boolean; + publicWorkspaceOwnerId?: string; + } = {}, ) { vi.resetModules(); vi.doUnmock("@/lib/rag"); @@ -298,11 +305,15 @@ function mockRuntime( OPENAI_API_KEY: options.openAiKey ?? "sk-test", RAG_PROVIDER_MODE: options.providerMode ?? "auto", LOCAL_NO_AUTH_OWNER_EMAIL: options.localOwnerEmail, + PUBLIC_WORKSPACE_OWNER_ID: options.publicWorkspaceOwnerId, + NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: options.publicUploadsEnabled ? "true" : undefined, WORKER_STALE_AFTER_MINUTES: 10, WORKER_MAX_ATTEMPTS: 3, }, isDemoMode: () => false, isLocalNoAuthMode: () => Boolean(options.localNoAuth), + publicWorkspaceOwnerId: () => options.publicWorkspaceOwnerId ?? null, + publicUploadsEnabled: () => Boolean(options.publicUploadsEnabled), requireOpenAIEnv: () => undefined, requireServerEnv: () => undefined, })); @@ -322,6 +333,15 @@ function localPortRequest(port: number, path: string, init?: RequestInit) { return new Request(`http://localhost:${port}${path}`, init); } +function matchesOwnerReadScope(call: QueryCall, ownerId?: string | null) { + if (ownerId === undefined || ownerId === null) { + return call.filters.some((filter) => filter.column === "owner_id" && filter.value === null); + } + return call.orFilters.some( + (filter) => filter.includes(`owner_id.eq.${ownerId}`) && filter.includes("owner_id.is.null"), + ); +} + function authenticatedRequest(path: string, init?: RequestInit) { return request(path, { ...init, @@ -376,14 +396,18 @@ afterEach(() => { }); describe("private document API access", () => { - it("still requires a valid token even when local no-auth mode is enabled", async () => { - const client = createSupabaseMock(); + it("lists public documents without auth in local no-auth mode", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client, undefined, { localNoAuth: true }); const { GET } = await import("../src/app/api/documents/route"); const response = await GET(localPortRequest(4298, "/api/documents")); + const body = await payload(response); - expect(response.status).toBe(401); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents); + expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); expect(client.auth.getUser).not.toHaveBeenCalled(); }); @@ -398,144 +422,227 @@ describe("private document API access", () => { expect(response.status).toBe(200); expect(client.auth.getUser).toHaveBeenCalledTimes(1); }); - it("rejects local no-auth private calls from unmanaged localhost ports before Supabase access", async () => { + it("rejects local no-auth upload calls from unmanaged localhost ports before Supabase access", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); - const { GET } = await import("../src/app/api/documents/route"); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); - const response = await GET(localPortRequest(3000, "/api/documents")); + const response = await POST( + localPortRequest(3000, "/api/upload", { + method: "POST", + body: formData, + }), + ); - expect(response.status).toBe(401); + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); - it("rejects managed-port private calls with stale localhost referers before Supabase access", async () => { + it("rejects managed-port upload calls with stale localhost referers before Supabase access", async () => { const client = createSupabaseMock(); mockRuntime(client, undefined, { localNoAuth: true }); - const { GET } = await import("../src/app/api/documents/route"); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["hello"], "guideline.txt", { type: "text/plain" })); - const response = await GET( - localPortRequest(4298, "/api/documents", { + const response = await POST( + localPortRequest(4298, "/api/upload", { + method: "POST", headers: { referer: "http://localhost:3000/", }, + body: formData, }), ); - expect(response.status).toBe(401); + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); - it("resolves local no-auth owner from documents before listing auth users", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; - const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.selected === "owner_id") { - return ok({ owner_id: userId }); - } - if (call.table === "documents") { - return ok(documents); - } - return ok([]); - }); - mockRuntime(client, undefined, { localNoAuth: true }); + it("lists public documents for unauthenticated callers", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(localPortRequest(4298, "/api/documents")); + const response = await GET(request("/api/documents?includeMeta=false")); const body = await payload(response); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents); expect(client.auth.getUser).not.toHaveBeenCalled(); - expect(client.from).not.toHaveBeenCalled(); + expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null }); + expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId }); }); - it("resolves configured local no-auth owner email before document fallback", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Configured owner document" }]; - const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.selected === "owner_id") { - return ok({ owner_id: otherUserId }); - } - if (call.table === "documents") { - return ok(documents); - } - return ok([]); - }); - client.auth.admin.listUsers.mockResolvedValueOnce({ - data: { users: [{ id: userId, email: "clinician@example.test" }], nextPage: 0 }, - error: null, - }); - mockRuntime(client, undefined, { localNoAuth: true, localOwnerEmail: "clinician@example.test" }); + it("filters authenticated document listing by owner and public rows", async () => { + const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); + mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(localPortRequest(4298, "/api/documents")); + const response = await GET(authenticatedRequest("/api/documents")); const body = await payload(response); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(client.auth.admin.listUsers).not.toHaveBeenCalled(); - expect(client.from).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(body.pagination).toMatchObject({ limit: 100, offset: 0, nextOffset: 1, hasMore: false }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + expect(client.calls[0].selected).toContain("storage_path"); + expect(client.calls[0].range).toEqual({ from: 0, to: 99 }); }); - it("rejects unauthenticated document listing", async () => { - const client = createSupabaseMock(); + it("accepts legacy Supabase auth cookies for private document access", async () => { + const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; + const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(request("/api/documents")); + const response = await GET(authenticatedCookieRequest("/api/documents")); + const body = await payload(response); - expect(response.status).toBe(401); - expect(await payload(response)).toEqual({ error: "Authentication required." }); - expect(client.from).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(client.auth.getUser).toHaveBeenCalledWith(token); + expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); }); - it("filters authenticated document listing by owner", async () => { + it("accepts Supabase auth token cookies for private document access", async () => { const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(authenticatedRequest("/api/documents")); + const response = await GET(authenticatedAuthTokenCookieRequest("/api/documents")); const body = await payload(response); expect(response.status).toBe(200); + expect(client.auth.getUser).toHaveBeenCalledWith(token); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(body.pagination).toMatchObject({ limit: 100, offset: 0, nextOffset: 1, hasMore: false }); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); - expect(client.calls[0].selected).toContain("id,owner_id,title"); - expect(client.calls[0].selected).not.toBe("*"); - expect(client.calls[0].range).toEqual({ from: 0, to: 99 }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); }); - it("accepts legacy Supabase auth cookies for private document access", async () => { + it("allows authenticated users to read public document detail", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok({ + id: documentId, + owner_id: null, + title: "Public guideline", + file_name: "guideline.pdf", + file_type: "application/pdf", + page_count: 2, + chunk_count: 1, + metadata: { index_generation_id: "generation-a" }, + }); + } + if (call.table === "document_pages") return ok([]); + if (call.table === "document_images") return ok([]); + if (call.table === "document_chunks") return ok([]); + if (call.table === "document_table_facts") return ok([]); + return ok([]); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/route"); + + const response = await GET(authenticatedRequest(`/api/documents/${documentId}`), { + params: Promise.resolve({ id: documentId }), + }); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.document).toMatchObject({ id: documentId, title: "Public guideline", owner_id: null }); + expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`); + }); + + it("allows authenticated users to open public document signed URLs", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { + return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" }); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(authenticatedRequest(`/api/documents/${documentId}/signed-url`), { + params: Promise.resolve({ id: documentId }), + }); + + expect(response.status).toBe(200); + expect((await payload(response)).url).toContain("public/documents/guideline.pdf"); + }); + + it("recovers valid cookie auth when a stale bearer header is also present", async () => { const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(authenticatedCookieRequest("/api/documents")); + const response = await GET( + request("/api/documents", { + headers: { + authorization: "Bearer expired-token", + cookie: `sb-access-token=${token}`, + }, + }), + ); const body = await payload(response); expect(response.status).toBe(200); - expect(client.auth.getUser).toHaveBeenCalledWith(token); expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); + expect(client.auth.getUser).toHaveBeenCalledWith(token); }); - it("accepts Supabase auth token cookies for private document access", async () => { - const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }]; + it("omits internal document list fields for anonymous callers", async () => { + const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }]; const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([]))); mockRuntime(client); const { GET } = await import("../src/app/api/documents/route"); - const response = await GET(authenticatedAuthTokenCookieRequest("/api/documents")); + const response = await GET(request("/api/documents?includeMeta=true")); const body = await payload(response); expect(response.status).toBe(200); - expect(client.auth.getUser).toHaveBeenCalledWith(token); - expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null }))); - expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); + expect(client.calls[0].selected).not.toContain("storage_path"); + expect(client.calls[0].selected).not.toContain("content_hash"); + expect(body.documents).toEqual(documents); + }); + + it("rate limits anonymous document read bursts", async () => { + const client = createSupabaseMock((call) => (call.table === "documents" ? ok([]) : ok([]))); + mockRuntime(client); + client.rpc.mockImplementation(async (name: string, args?: Record) => { + if (name === "consume_api_subject_rate_limit" && args?.p_bucket === "document_read") { + return { + data: [rateLimitRow({ limited: true, remaining: 0, retry_after_seconds: 30 })], + error: null, + }; + } + if (name === "consume_api_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); + const { GET } = await import("../src/app/api/documents/route"); + + const response = await GET(request("/api/documents")); + + expect(response.status).toBe(429); + expect(await payload(response)).toMatchObject({ + error: "Document requests are rate limited. Try again shortly.", + retryAfterSeconds: 30, + }); + expect(client.rpc).toHaveBeenCalledWith( + "consume_api_subject_rate_limit", + expect.objectContaining({ p_bucket: "document_read" }), + ); }); it("does not return raw internal database errors", async () => { @@ -567,7 +674,7 @@ describe("private document API access", () => { it("allows document signed URLs only for owned documents", async () => { const client = createSupabaseMock((call) => { - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ storage_path: `${userId}/documents/${documentId}/source.pdf`, file_type: "application/pdf" }); } return ok(null); @@ -602,6 +709,48 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("allows anonymous signed URLs for public documents", async () => { + const client = createSupabaseMock((call) => { + if ( + call.table === "documents" && + call.filters.some((filter) => filter.column === "owner_id" && filter.value === null) + ) { + return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" }); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(request(`/api/documents/${documentId}/signed-url`), { + params: Promise.resolve({ id: documentId }), + }); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.url).toContain("public/documents/guideline.pdf"); + expect(client.auth.getUser).not.toHaveBeenCalled(); + }); + + it("rejects anonymous signed URLs for private documents", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.filters.some((filter) => filter.column === "owner_id")) { + return ok(null); + } + return ok(null); + }); + mockRuntime(client); + const { GET } = await import("../src/app/api/documents/[id]/signed-url/route"); + + const response = await GET(request(`/api/documents/${otherDocumentId}/signed-url`), { + params: Promise.resolve({ id: otherDocumentId }), + }); + + expect(response.status).toBe(404); + expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); + }); + it("allows image signed URLs only when the parent document is owned", async () => { const client = createSupabaseMock((call) => { if (call.table === "document_images") { @@ -613,7 +762,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-a" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: { index_generation_id: "generation-a" } }); } return ok(null); @@ -642,7 +791,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-a" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: {} }); } return ok(null); @@ -671,7 +820,7 @@ describe("private document API access", () => { metadata: { index_generation_id: "generation-new" }, }); } - if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) { + if (call.table === "documents" && matchesOwnerReadScope(call, userId)) { return ok({ id: documentId, metadata: { index_generation_id: "generation-old" } }); } return ok(null); @@ -711,6 +860,59 @@ describe("private document API access", () => { expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); + it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + request("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(503); + expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect(client.storageMocks.upload).not.toHaveBeenCalled(); + }); + + it("uploads anonymous documents to the configured public workspace owner", async () => { + const publicOwnerId = "99999999-9999-4999-8999-999999999999"; + const client = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); + if (call.table === "documents" && call.operation === "insert") { + const inserted = call.insertPayload as { id: string; owner_id: string; storage_path: string }; + return ok({ id: inserted.id, owner_id: inserted.owner_id, storage_path: inserted.storage_path }); + } + if (call.table === "ingestion_jobs" && call.operation === "insert") + return ok({ id: "job-1", document_id: documentId }); + return ok([]); + }); + mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId }); + const { POST } = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + + const response = await POST( + request("/api/upload", { + method: "POST", + body: formData, + }), + ); + + expect(response.status).toBe(201); + expect(client.auth.getUser).not.toHaveBeenCalled(); + expect( + client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload, + ).toMatchObject({ + owner_id: publicOwnerId, + }); + }); + it("stores uploaded documents with owner_id and a user-scoped storage path", async () => { const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") { @@ -2095,9 +2297,13 @@ describe("private document API access", () => { } return ok([]); }); - client.rpc.mockImplementation(async (name: string) => - name === "search_document_chunks" ? fail("missing rpc") : ok([]), - ); + client.rpc.mockImplementation(async (name: string) => { + if (name === "search_document_chunks") return fail("missing rpc"); + if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") { + return { data: [rateLimitRow()], error: null }; + } + return ok([]); + }); mockRuntime(client); const { GET } = await import("../src/app/api/documents/[id]/search/route"); @@ -2788,6 +2994,41 @@ describe("private document API access", () => { ); }); + it("degrades invalid bearer tokens to anonymous search scope", async () => { + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { + search_cache_hit: false, + text_fast_path_latency_ms: 0, + embedding_skipped: true, + embedding_latency_ms: 0, + embedding_cache_hit: false, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + retrieval_strategy: "text_fast_path", + }, + })); + const client = createSupabaseMock(); + mockRuntime(client, { searchChunksWithTelemetry }); + const searchRoute = await import("../src/app/api/search/route"); + + const response = await searchRoute.POST( + request("/api/search", { + method: "POST", + headers: { + authorization: "Bearer expired-token", + "content-type": "application/json", + }, + body: JSON.stringify({ query: "monitoring" }), + }), + ); + + expect(response.status).toBe(200); + expect(searchChunksWithTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ ownerId: undefined, allowGlobalSearch: true }), + ); + }); + it("rate limits anonymous answer bursts before generation", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Public evidence.", @@ -2957,7 +3198,9 @@ describe("private document API access", () => { })); const client = createSupabaseMock(); client.rpc.mockImplementation(async (name: string) => - name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]), + name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit" + ? fail("limiter table unavailable") + : ok([]), ); mockRuntime(client, { searchChunksWithTelemetry }); const { POST } = await import("../src/app/api/search/route"); diff --git a/tests/public-access-deep.test.ts b/tests/public-access-deep.test.ts new file mode 100644 index 000000000..9d0cf39d1 --- /dev/null +++ b/tests/public-access-deep.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const publicDocumentId = "11111111-1111-4111-8111-111111111111"; + +beforeEach(() => { + vi.resetModules(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); +}); + +describe("public access deep checks", () => { + it("rejects unauthenticated access to operator-only routes", async () => { + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + const auth = { + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => { + throw new auth.AuthenticationError(); + }), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + }; + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + + const cases = [ + { + routePath: "../src/app/api/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/jobs"), + }, + { + routePath: "../src/app/api/ingestion/jobs/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/jobs"), + }, + { + routePath: "../src/app/api/ingestion/batches/route", + handler: "GET" as const, + request: new Request("http://localhost/api/ingestion/batches"), + }, + { + routePath: "../src/app/api/documents/bulk/route", + handler: "POST" as const, + request: new Request("http://localhost/api/documents/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "delete", documentIds: [publicDocumentId] }), + }), + }, + { + routePath: "../src/app/api/documents/[id]/summarize/route", + handler: "POST" as const, + request: new Request(`http://localhost/api/documents/${publicDocumentId}/summarize`, { method: "POST" }), + params: { id: publicDocumentId }, + }, + { + routePath: "../src/app/api/eval-cases/route", + handler: "POST" as const, + request: new Request("http://localhost/api/eval-cases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: "What monitoring is needed?", + rating: "good", + answer: "Monitor FBC.", + queryMode: "auto", + queryClass: "table_threshold", + }), + }), + }, + ] as const; + + for (const testCase of cases) { + vi.resetModules(); + vi.doMock("@/lib/env", () => ({ isDemoMode: () => false, env: {} })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ auth: { getUser: vi.fn() } })), + })); + vi.doMock("@/lib/supabase/auth", () => auth); + const mod = await import(testCase.routePath); + const handler = mod[testCase.handler]; + const response = await handler( + testCase.request, + "params" in testCase ? { params: Promise.resolve(testCase.params) } : undefined, + ); + expect(response.status, testCase.routePath).toBe(401); + } + }); + + it("does not expose secret values from the health endpoint", async () => { + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_SERVICE_ROLE_KEY: "super-secret-service-role-key", + OPENAI_API_KEY: "sk-super-secret-openai-key", + }, + isDemoMode: () => false, + })); + const { GET } = await import("../src/app/api/health/route"); + const response = await GET(new Request("https://clinical.example/api/health?deep=1")); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(serialized).not.toContain("super-secret-service-role-key"); + expect(serialized).not.toContain("sk-super-secret-openai-key"); + expect(body.checks.supabaseConfig).toBe("ok"); + expect(body.checks.openaiConfig).toBe("ok"); + }); +}); + +describe("production anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("fails closed in production when anonymous global retrieval omits owner scope", async () => { + vi.doUnmock("@/lib/rag"); + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: "sk-test", + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + + await expect( + searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }), + ).rejects.toThrow(/ownerId|tenant/i); + }); +}); + +describe("test-runtime anonymous retrieval scope", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllEnvs(); + }); + + it("allows anonymous global retrieval in test runtime where owner scope stays permissive", async () => { + vi.doUnmock("@/lib/rag"); + vi.doMock("@/lib/env", () => ({ + env: { + OPENAI_API_KEY: undefined, + RAG_PROVIDER_MODE: "offline", + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + }, + isDemoMode: () => false, + isLocalNoAuthMode: () => false, + requestedOpenAIAnswerModels: () => ({ fast: "gpt-test", strong: "gpt-test" }), + })); + vi.doMock("@/lib/supabase/admin", () => ({ + createAdminClient: vi.fn(() => ({ + from: vi.fn(() => ({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + is: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + in: vi.fn(() => ({ + order: vi.fn(() => ({ + limit: vi.fn(async () => ({ data: [], error: null })), + })), + })), + })), + })), + })), + })), + rpc: vi.fn(async () => ({ data: [], error: null })), + })), + })); + + const { searchChunksWithTelemetry } = await import("../src/lib/rag"); + const result = await searchChunksWithTelemetry({ + query: "clozapine monitoring", + allowGlobalSearch: true, + }); + + expect(result.results).toEqual([]); + expect(result.telemetry).toBeDefined(); + }); +}); diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts index 388bd52e5..5571c270c 100644 --- a/tests/registry-records-route.test.ts +++ b/tests/registry-records-route.test.ts @@ -198,11 +198,8 @@ describe("registry records API", () => { expect(payload.records.some((record) => record.slug === "13yarn")).toBe(true); expect(payload.matches?.[0]?.record.slug).toBe("13yarn"); expect(client.from).not.toHaveBeenCalled(); - expect(client.rpc).toHaveBeenCalledWith( - "consume_api_subject_rate_limit", - expect.objectContaining({ p_bucket: "registry" }), - ); - expect(client.rpc).not.toHaveBeenCalledWith("consume_api_rate_limit", expect.anything()); + expect(client.rpc).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); }); it("rejects an invalid kind with a validation error", async () => { @@ -311,11 +308,8 @@ describe("registry records API", () => { expect(payload.record.slug).toBe("13yarn"); expect(payload.linkedDocuments).toEqual([]); expect(client.from).not.toHaveBeenCalled(); - expect(client.rpc).toHaveBeenCalledWith( - "consume_api_subject_rate_limit", - expect.objectContaining({ p_bucket: "registry" }), - ); - expect(client.rpc).not.toHaveBeenCalledWith("consume_api_rate_limit", expect.anything()); + expect(client.rpc).not.toHaveBeenCalled(); + expect(client.auth.getUser).not.toHaveBeenCalled(); }); it("returns 404 for an unknown slug", async () => { diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index ddb62411e..a2f06474e 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -127,6 +127,18 @@ describe("retrieval query variants", () => { expect(textCandidateBudgetForQueryClass("unsupported_or_general", 12)).toBe(24); }); + it("keeps forced-embedding retrieval out of the ordinary search cache key", () => { + const baseArgs = { + query: "How is panic disorder managed?", + topK: 8, + minSimilarity: 0.12, + }; + + expect(retrievalPlanCacheQuery(baseArgs, "broad_summary")).not.toBe( + retrievalPlanCacheQuery({ ...baseArgs, forceEmbedding: true }, "broad_summary"), + ); + }); + it("allows direct document title hits to skip embedding retrieval", () => { expect( decideTextFastPath( diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts index 170aac7f5..f952b2ef8 100644 --- a/tests/setup-status-route.test.ts +++ b/tests/setup-status-route.test.ts @@ -7,10 +7,13 @@ afterEach(() => { }); describe("/api/setup-status", () => { - it("requires auth for non-local production requests before returning setup posture", async () => { + it("returns setup posture for anonymous production requests without exposing secret values", async () => { vi.stubEnv("NODE_ENV", "production"); - const getUser = vi.fn(); - const createAdminClient = vi.fn(() => ({ auth: { getUser } })); + const from = vi.fn(async () => ({ error: null, data: [], count: 0 })); + const createAdminClient = vi.fn(() => ({ + from, + rpc: vi.fn(), + })); vi.doMock("@/lib/env", () => ({ env: { NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", @@ -24,16 +27,29 @@ describe("/api/setup-status", () => { isLocalNoAuthMode: () => false, })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/health", () => ({ + probeSupabaseHealth: vi.fn(async () => ({ ok: true })), + isSupabaseUnavailableError: () => false, + formatSupabaseUnavailableError: (error: unknown) => String(error), + })); + vi.doMock("@/lib/supabase/project", () => ({ + checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }), + formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.", + })); const { GET } = await import("../src/app/api/setup-status/route"); const response = await GET(new Request("https://clinical.example/api/setup-status")); const body = await response.json(); - expect(response.status).toBe(401); - expect(body).toEqual({ error: "Authentication required." }); - expect(JSON.stringify(body)).not.toContain("OPENAI"); - expect(JSON.stringify(body)).not.toContain("Supabase"); - expect(createAdminClient).toHaveBeenCalledTimes(1); - expect(getUser).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + expect(body).toMatchObject({ + demoMode: false, + checks: expect.arrayContaining([ + expect.objectContaining({ id: "env" }), + expect.objectContaining({ id: "openai" }), + ]), + }); + expect(JSON.stringify(body)).not.toContain("service-role-key"); + expect(JSON.stringify(body)).not.toContain("openai-key"); }); }); diff --git a/tests/supabase-schema.test.ts b/tests/supabase-schema.test.ts index 68cd98773..6dd16d6c9 100644 --- a/tests/supabase-schema.test.ts +++ b/tests/supabase-schema.test.ts @@ -80,6 +80,10 @@ const registryCatalogPayloadMigration = readFileSync( new URL("../supabase/migrations/20260705030000_registry_catalog_payload.sql", import.meta.url), "utf8", ).replace(/\s+/g, " "); +const searchHealthIndexesMigration = readFileSync( + new URL("../supabase/migrations/20260705180000_reconcile_search_health_indexes.sql", import.meta.url), + "utf8", +).replace(/\s+/g, " "); const ragQueriesRetentionMigration = readFileSync( new URL("../supabase/migrations/20260629060603_rag_queries_retention.sql", import.meta.url), "utf8", @@ -739,6 +743,17 @@ describe("Supabase schema Data API grants", () => { "add column if not exists catalog_payload jsonb not null default '{}'::jsonb", ); }); + + it("reconciles search_schema_health index drift with canonical creates and live aliases", () => { + expect(searchHealthIndexesMigration).toContain("create index if not exists documents_title_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists document_labels_label_trgm_idx"); + expect(searchHealthIndexesMigration).toContain("create index if not exists rag_retrieval_logs_miss_idx"); + expect(searchHealthIndexesMigration).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(searchHealthIndexesMigration).toContain("'documents_title_search_tsv_idx'"); + expect(searchHealthIndexesMigration).toContain("'document_pages_document_id_page_number_key'"); + expect(schema).toContain("index_aliases constant jsonb := jsonb_build_object("); + expect(schema).toContain("jsonb_array_elements_text(index_aliases -> index_name)"); + }); }); describe("RC9 — lexical text path must not fabricate a cosine similarity", () => { diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 3d2bae636..2eb32ebbf 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -143,6 +143,7 @@ async function mockLocalProjectIdentity(page: Page) { } async function mockPrivateUnauthenticatedApi(page: Page) { + await mockLocalProjectIdentity(page); await page.route("**/api/setup-status**", async (route) => { await route.fulfill({ json: { demoMode: false, checks: readySetupChecks }, @@ -706,6 +707,36 @@ test.describe("Clinical KB UI smoke coverage", () => { }); } + test("anonymous user can see enabled live search without a forced sign-in gate", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } }); + }); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); + }); + + test("anonymous mobile user can search without a forced sign-in gate", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await mockPrivateUnauthenticatedApi(page); + await page.route(/\/api\/search(?:\?.*)?$/, async (route) => { + await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } }); + }); + await gotoApp(page, "/"); + await waitForDemoDashboardReady(page); + + await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0); + await expect(page.getByText("Service unavailable")).toHaveCount(0); + await expect(page.getByText("API unavailable")).toHaveCount(0); + await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0); + await expect(page.getByTestId("global-search-input")).toBeEnabled(); + }); + test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockDemoApi(page); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index ef9823b7d..3037cfaa4 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -524,12 +524,13 @@ test.describe("Clinical KB applications launcher", () => { test("mode home deep links preserve focus=1 on initial load", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); - for (const path of ["/services?focus=1", "/forms?focus=1"]) { - await gotoLauncher(page, path); - const sharedSearch = page.getByTestId("global-search-input"); - await expect(sharedSearch).toBeVisible(); - await expect(sharedSearch).toBeFocused(); - } + await gotoLauncher(page, "/services?focus=1"); + await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused(); + + await gotoLauncher(page, "/forms?focus=1"); + await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeVisible(); + await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeFocused(); }); test("services mode shows source-backed records in search results", async ({ page }) => { @@ -573,11 +574,12 @@ test.describe("Clinical KB applications launcher", () => { test("form detail pages keep the shared forms search wired to form results", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/forms/transport-crisis-form"); + await expect(page.getByTestId("form-detail-page")).toBeVisible(); // Structural coverage — runs on every browser, WebKit included: the form // detail page renders inside the shared shell with the Forms-mode composer // present and no stale results. - await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole("heading", { level: 1, name: "Transport order" })).toBeVisible(); await expect(page.getByTestId("form-search-results")).toHaveCount(0); const formsSearchInput = page.locator('input[placeholder="Search forms..."]:visible').first();