From 3305b78602e443e2662e59533b84954032aa6acf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:07:56 +0800 Subject: [PATCH 1/2] fix: remediate repository review findings Harden database concurrency, privacy, request limits, source governance, document scope, and accessibility. Regenerate the drift manifest after local schema replay and retain provider reconciliation as a follow-up against current main. --- .env.example | 2 + .github/workflows/eval-canary.yml | 9 +- docs/branch-review-ledger.md | 1 + docs/deployment-architecture.md | 5 + next.config.ts | 5 + src/app/api/answer/route.ts | 14 + src/app/api/answer/stream/route.ts | 16 + src/app/api/documents/[id]/reindex/route.ts | 142 +--- src/app/api/documents/[id]/route.ts | 53 +- src/app/api/documents/bulk/reindex/route.ts | 112 +-- src/app/api/documents/route.ts | 4 - src/app/api/setup-status/route.ts | 21 +- src/app/api/upload/route.ts | 6 + src/app/not-found.tsx | 2 + src/components/AccessibleTable.tsx | 27 +- src/components/ClinicalDashboard.tsx | 163 +++-- src/components/DocumentViewer.tsx | 6 +- src/components/ServiceDetailPage.tsx | 1 - src/components/clinical-dashboard-client.tsx | 19 - src/components/clinical-dashboard-lazy.tsx | 11 - .../favourites-home-page.tsx | 35 - .../document-search-live-opener.tsx | 665 ------------------ src/components/document-viewer-client.tsx | 17 - src/components/forms/form-detail-page.tsx | 18 +- src/components/mode-home-page-skeleton.tsx | 4 +- .../services/service-detail-page.tsx | 5 +- .../services/services-navigator-page.tsx | 6 + src/components/ui/sheet.tsx | 9 +- src/lib/answer-render-policy.ts | 16 +- src/lib/answer-telemetry.ts | 25 +- src/lib/answer-thread-storage.ts | 22 +- src/lib/answer-verification.ts | 10 +- src/lib/env.ts | 2 +- src/lib/evidence.ts | 16 +- src/lib/extractors/document.ts | 17 + src/lib/rag-cache.ts | 10 +- src/lib/rag-quote-verification.ts | 3 +- src/lib/rag.ts | 25 +- src/lib/supabase/database.types.ts | 4 + src/lib/validation/body.ts | 48 +- src/proxy.ts | 14 + supabase/drift-manifest.json | 32 +- supabase/functions/indexing-v3-agent/index.ts | 161 +++-- ...11120000_fence_index_generation_commit.sql | 53 ++ ...21000_purge_expired_rag_response_cache.sql | 32 + ...1122000_route_enrichment_through_agent.sql | 166 +++++ supabase/schema.sql | 153 ++++ tests/answer-thread-storage.test.ts | 32 +- tests/answer-verification.test.ts | 31 + tests/bounded-json-body.test.ts | 36 + tests/eval-canary-workflow.test.ts | 14 + tests/indexing-v3-agent.test.ts | 22 + tests/private-access-routes.test.ts | 59 +- tests/rag-trust.test.ts | 8 +- tests/setup-status-route.test.ts | 23 +- tests/upload-ingress-limits.test.ts | 16 + tests/worker-visual-capture.test.ts | 6 +- worker/main.ts | 39 +- 58 files changed, 1171 insertions(+), 1302 deletions(-) delete mode 100644 src/components/ServiceDetailPage.tsx delete mode 100644 src/components/clinical-dashboard-client.tsx delete mode 100644 src/components/clinical-dashboard-lazy.tsx delete mode 100644 src/components/clinical-dashboard/favourites-home-page.tsx delete mode 100644 src/components/document-search-live-opener.tsx delete mode 100644 src/components/document-viewer-client.tsx create mode 100644 supabase/migrations/20260711120000_fence_index_generation_commit.sql create mode 100644 supabase/migrations/20260711121000_purge_expired_rag_response_cache.sql create mode 100644 supabase/migrations/20260711122000_route_enrichment_through_agent.sql create mode 100644 tests/bounded-json-body.test.ts create mode 100644 tests/eval-canary-workflow.test.ts create mode 100644 tests/upload-ingress-limits.test.ts diff --git a/.env.example b/.env.example index cdf6c9c01..5fc85b0e2 100644 --- a/.env.example +++ b/.env.example @@ -100,6 +100,8 @@ SUPABASE_IMAGE_BUCKET=clinical-images # Conservative local-first defaults. Raise only after testing your worker machine, # Supabase plan limits, and confidentiality policy. MAX_UPLOAD_MB=150 +# Next Proxy has a fixed 151 MiB transport envelope (150 MiB file plus +# multipart framing), so values above 150 are intentionally rejected. MAX_IMPORT_JOBS_PER_RUN=5 MAX_IMPORT_BYTES_PER_RUN=157286400 CHUNK_SIZE=2000 diff --git a/.github/workflows/eval-canary.yml b/.github/workflows/eval-canary.yml index dcf871e53..f8f48becb 100644 --- a/.github/workflows/eval-canary.yml +++ b/.github/workflows/eval-canary.yml @@ -91,7 +91,14 @@ jobs: run: npm run eval:retrieval:quality -- --fail-on-threshold - name: Answer-quality subset (live generation) - run: npm run eval:quality -- --rag-only --limit ${{ github.event.inputs.answer_case_limit || '8' }} --fail-on-threshold + env: + ANSWER_CASE_LIMIT: ${{ github.event.inputs.answer_case_limit || '8' }} + run: | + if [[ ! "$ANSWER_CASE_LIMIT" =~ ^[0-9]+$ ]] || (( ANSWER_CASE_LIMIT < 1 || ANSWER_CASE_LIMIT > 100 )); then + echo "answer_case_limit must be an integer from 1 to 100" >&2 + exit 2 + fi + npm run eval:quality -- --rag-only --limit "$ANSWER_CASE_LIMIT" --fail-on-threshold - name: Open or update regression issue if: failure() && github.event_name == 'schedule' diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 14570b6fe..48f40d1c3 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -47,3 +47,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | | 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | | 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | +| 2026-07-11 | codex/repository-review-remediation | 70ec6409a11a85e1678eb4b320519624673a94a0 | comprehensive repository report remediation | Revalidated all 17 findings from the 2026-07-09 comprehensive review. Remediated the current workflow injection, document scope, numeric faithfulness, ingestion lease/ownership, transactional enrichment replacement, request and upload budgets, browser identity isolation, PHI retention, public DTO, cache cancellation/versioning, evidence labelling, modal focus, misleading controls, telemetry, orphan-module issues, and two server/client loading-boundary failures exposed during browser QA. The runbook filename was already fixed on the reviewed head. | TypeScript, lint, focused Vitest (68/68), full offline Vitest (1,607/1,607; 1 skipped), production build, client-bundle secret scan, Docker schema replay and regenerated drift manifest, isolated full migration reset, local lease-reclaim concurrency proof, cache/enrichment SQL smoke, configured production-readiness (`READY`), Chromium document-scope/modal QA (4/4), manual disabled-control accessibility snapshots, and `git diff --check` passed. Read-only live drift found 26 unexpected differences, including the three unapplied remediation functions; no live mutation was performed. | diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index b2130c7b0..b543c40e4 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -84,6 +84,11 @@ them only after the shared `rag_response_cache` hit rate is confirmed healthy. deliberately bypassed), and a `HEALTHCHECK` against `/api/health`. - No secret is ever baked into a layer. `SUPABASE_SERVICE_ROLE_KEY`, `OPENAI_API_KEY`, etc. are injected at run time by the host's secret store. +- Request bodies are bounded twice: Next Proxy buffers at most 151 MiB, and + `/api/upload` rejects declared multipart bodies above `MAX_UPLOAD_MB` plus + 1 MiB framing overhead before authentication or `request.formData()`. Keep + the managed host's request-body limit at 151 MiB or lower as a third ingress + fence; `MAX_UPLOAD_MB` is capped at 150 MiB by environment validation. Minimal Fly config (create at deploy time; not committed until an app is provisioned): diff --git a/next.config.ts b/next.config.ts index 89fcad349..2654b9e1d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -26,6 +26,11 @@ const nextConfig: NextConfig = { experimental: { cpus: 1, optimizePackageImports: ["lucide-react"], + // Proxy is on every API route. Bound its buffered client body so a + // chunked multipart upload cannot grow without limit before route code + // reaches request.formData(). MAX_UPLOAD_MB is capped at 150 below this + // 151 MiB transport envelope (1 MiB reserved for multipart framing). + proxyClientMaxBodySize: "151mb", }, poweredByHeader: false, turbopack: { diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index 0c902a48a..4b226f8e4 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -143,6 +143,20 @@ export async function POST(request: Request) { // (refused) sources/smartPanel/smartApiPlan would still reach the client and // defeat the refusal. Keep only the safe "unsupported" contract fields, matching // the empty-scope branch above. + void logAnswerDiagnostics({ + supabase, + query: answerBody.query, + ownerId: access.ownerId, + answer: { + ...answer, + grounded: false, + confidence: "unsupported", + sources: [], + responseMode: "evidence_gap", + fallbackReason: "source_governance_refusal", + routingReason: [answer.routingReason, "source_governance_refusal"].filter(Boolean).join("; "), + }, + }); return NextResponse.json({ answer: sourceGovernanceRefusalAnswer, grounded: false, diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index e44b57281..3af87a321 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -214,6 +214,22 @@ function streamAnswer(body: AnswerBody, ownerId?: string, signal?: AbortSignal, if (shouldUseSourceGovernanceRefusal && hasDangerSourceGovernanceWarning(warnings)) { // Explicit refusal payload — do not spread ...answer (see /api/answer): // the refused sources/smartPanel/smartApiPlan must not reach the client. + if (!isDemoMode()) { + void logAnswerDiagnostics({ + supabase: createAdminClient(), + query: body.query, + ownerId, + answer: { + ...answer, + grounded: false, + confidence: "unsupported", + sources: [], + responseMode: "evidence_gap", + fallbackReason: "source_governance_refusal", + routingReason: [answer.routingReason, "source_governance_refusal"].filter(Boolean).join("; "), + }, + }); + } send("final", { answer: sourceGovernanceRefusalAnswer, grounded: false, diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 1d1ddde96..f6e3f2374 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -1,25 +1,17 @@ import { NextResponse } from "next/server"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { env, isDemoMode } from "@/lib/env"; -import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; -import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; import { jsonError } from "@/lib/http"; import { activeIngestionJobColumns, buildActiveJobsSafetyResult, checkIngestionMutationSafety, - hasActiveAgentEnrichmentJob, ingestionMutationSafetyPayload, ingestionRollbackFenceStamp, type IngestionJobRow, } from "@/lib/ingestion-mutation-safety"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; -import { - committedIndexGeneration, - isAtomicReindexCandidate, - isCommittedGenerationMetadata, -} from "@/lib/reindex-pipeline"; +import { isAtomicReindexCandidate } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBodyOrDefault } from "@/lib/validation/body"; @@ -27,7 +19,6 @@ import { parseRouteParams } from "@/lib/validation/params"; export const runtime = "nodejs"; -const reindexPageSize = 1000; const reindexModeSchema = z .object({ mode: z.preprocess((value) => (value === "enrichment" ? "enrichment" : "full"), z.enum(["full", "enrichment"])), @@ -37,73 +28,11 @@ const reindexRouteParamsSchema = z.object({ id: z.string().uuid(), }); -type ReindexChunk = { - id: string; - document_id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids?: string[] | null; - metadata?: Record | null; -}; - -type ReindexImage = { - id: string; - page_number: number | null; - caption: string | null; - image_type: string | null; - labels?: string[] | null; - source_kind?: string | null; - clinical_relevance_score?: number | null; - metadata?: Record | null; -}; - -function committedReindexRows(document: { metadata?: unknown }, rows: T[]) { - const committedGeneration = committedIndexGeneration(document.metadata); - return rows.filter((row) => - isCommittedGenerationMetadata({ - rowMetadata: row.metadata, - committedGeneration, - }), - ); -} - async function readMode(request: Request) { const parsed = await parseJsonBodyOrDefault(request, reindexModeSchema, { mode: "full" }); return parsed.mode; } -async function selectReindexRowsInPages(args: { - supabase: ReturnType; - table: "document_chunks" | "document_images"; - select: string; - documentId: string; - searchableOnly?: boolean; -}) { - const rows: T[] = []; - if (args.searchableOnly && args.table !== "document_images") { - throw new Error("searchableOnly reindex paging only supports the document_images table."); - } - for (let offset = 0; ; offset += reindexPageSize) { - const dynamicSupabase = args.supabase as unknown as SupabaseClient; - const query = args.searchableOnly - ? dynamicSupabase - .from("document_images") - .select(args.select) - .eq("document_id", args.documentId) - .eq("searchable", true) - : dynamicSupabase.from(args.table).select(args.select).eq("document_id", args.documentId); - const { data, error } = await query.range(offset, offset + reindexPageSize - 1); - if (error) throw new Error(error.message); - - const page = (data ?? []) as T[]; - rows.push(...page); - if (page.length < reindexPageSize) break; - } - return rows; -} - export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { if (isDemoMode()) return NextResponse.json({ error: "Reindex is unavailable in demo mode." }, { status: 400 }); @@ -139,74 +68,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); if (mode === "enrichment") { - // Audit R24d: route-mode enrichment and the enrichment agent both - // delete-then-insert the same artifact families with no shared lock. If - // the agent is mid-pass, the interleaved deletes can strand a - // "completed/good" document with zero enrichment artifacts (no repair - // path exists). Refuse to run while a live agent pass holds the document. - const agentBusy = await hasActiveAgentEnrichmentJob({ - supabase, - documentId: id, - staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, + const { data: queued, error: queueError } = await supabase.rpc("request_indexing_v3_enrichment", { + p_document_id: id, + p_owner_id: user.id, }); - if (agentBusy) { + if (queueError) { return NextResponse.json( - { - error: - "The enrichment agent is currently processing this document. Wait for it to finish before re-running enrichment.", - }, + { error: "Enrichment is already active or could not be queued safely." }, { status: 409 }, ); } - - const [chunks, images] = await Promise.all([ - selectReindexRowsInPages({ - supabase, - table: "document_chunks", - select: "id,document_id,page_number,chunk_index,section_heading,content,image_ids,metadata", - documentId: id, - }), - selectReindexRowsInPages({ - supabase, - table: "document_images", - select: "id,page_number,caption,image_type,labels,source_kind,clinical_relevance_score,metadata", - documentId: id, - searchableOnly: true, - }), - ]); - - const committedChunks = committedReindexRows(document, chunks); - const committedImages = committedReindexRows(document, images); - - committedChunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); - committedImages.sort((a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0)); - - if (!committedChunks.length) { - return NextResponse.json({ error: "Document has no indexed chunks to enrich." }, { status: 400 }); - } - - const enrichment = await upsertDocumentEnrichment({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - }); - const deepMemory = await upsertDocumentDeepMemory({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - summary: enrichment.summary.summary, - }); - return NextResponse.json({ - mode, - enrichment, - deepMemory: { - sectionCount: deepMemory.sections.length, - memoryCardCount: deepMemory.memoryCards.length, - indexUnitCount: deepMemory.indexUnits.length, - }, - }); + return NextResponse.json({ mode, queued }, { status: 202 }); } const atomicReindex = isAtomicReindexCandidate(document); diff --git a/src/app/api/documents/[id]/route.ts b/src/app/api/documents/[id]/route.ts index c339e642c..35bc57b16 100644 --- a/src/app/api/documents/[id]/route.ts +++ b/src/app/api/documents/[id]/route.ts @@ -381,16 +381,39 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: if (summaryResult.error) throw new Error(summaryResult.error.message); if (tableFactsResult.error) throw new Error(tableFactsResult.error.message); + const omitPublicInternalFields = (row: Record) => { + const internalKeys = new Set([ + "owner_id", + "storage_path", + "content_hash", + "source_path", + "import_batch_id", + "error_message", + "metadata", + ]); + return Object.fromEntries(Object.entries(row).filter(([key]) => !internalKeys.has(key))); + }; + const publicRows = >(rows: T[]) => + access.authenticated ? rows : rows.map(omitPublicInternalFields); + const responseDocument = access.authenticated + ? document + : omitPublicInternalFields(document as Record); + return NextResponse.json({ document: { - ...document, - labels: labelsResult.data ?? [], - summary: summaryResult.data ?? null, + ...responseDocument, + labels: publicRows((labelsResult.data ?? []) as Record[]), + summary: + access.authenticated || !summaryResult.data + ? (summaryResult.data ?? null) + : omitPublicInternalFields(summaryResult.data as Record), }, - pages: pages ?? [], - images: committedRows(document, images ?? []).map(withImageTableMetadata), - tableFacts: committedRows(document, tableFactsResult.data ?? []), - chunks: committedRows(document, chunks ?? []), + pages: publicRows((pages ?? []) as Record[]), + images: publicRows( + committedRows(document, images ?? []).map(withImageTableMetadata) as Record[], + ), + tableFacts: publicRows(committedRows(document, tableFactsResult.data ?? []) as Record[]), + chunks: publicRows(committedRows(document, chunks ?? []) as Record[]), pageWindow: { from: pageWindow.from, to: pageWindow.to, @@ -407,12 +430,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: hasAfter: Boolean(document.chunk_count && chunkRangeEnd + 1 < document.chunk_count), selectedChunkId: selectedChunk?.id ?? null, }, - indexHealth: { - extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null, - indexedAt: safeMetadata(document.metadata).indexed_at ?? null, - indexVersion: safeMetadata(document.metadata).rag_indexing_version ?? null, - warnings: safeMetadata(document.metadata).extraction_warnings ?? [], - }, + ...(access.authenticated + ? { + indexHealth: { + extractionQuality: safeMetadata(document.metadata).extraction_quality ?? null, + indexedAt: safeMetadata(document.metadata).indexed_at ?? null, + indexVersion: safeMetadata(document.metadata).rag_indexing_version ?? null, + warnings: safeMetadata(document.metadata).extraction_warnings ?? [], + }, + } + : {}), }); } catch (error) { if (error instanceof AuthenticationError) { diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index 2d146277f..27b099407 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from "next/server"; -import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; -import { upsertDocumentDeepMemory } from "@/lib/deep-memory"; -import { upsertDocumentEnrichment } from "@/lib/document-enrichment"; import { env, isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { @@ -15,11 +12,7 @@ import { } from "@/lib/ingestion-mutation-safety"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { invalidateRagCachesForOwner } from "@/lib/rag"; -import { - committedIndexGeneration, - isAtomicReindexCandidate, - isCommittedGenerationMetadata, -} from "@/lib/reindex-pipeline"; +import { isAtomicReindexCandidate } from "@/lib/reindex-pipeline"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseJsonBody } from "@/lib/validation/body"; @@ -31,69 +24,6 @@ const bulkReindexSchema = z.object({ mode: z.enum(["enrichment", "full", "retry_failed"]).default("enrichment"), }); -const pageSize = 1000; - -type ReindexChunk = { - id: string; - document_id: string; - page_number: number | null; - chunk_index: number; - section_heading: string | null; - content: string; - image_ids?: string[] | null; - metadata?: Record | null; -}; - -type ReindexImage = { - id: string; - page_number: number | null; - caption: string | null; - image_type: string | null; - labels?: string[] | null; - source_kind?: string | null; - clinical_relevance_score?: number | null; - metadata?: Record | null; -}; - -function committedReindexRows(document: { metadata?: unknown }, rows: T[]) { - const committedGeneration = committedIndexGeneration(document.metadata); - return rows.filter((row) => - isCommittedGenerationMetadata({ - rowMetadata: row.metadata, - committedGeneration, - }), - ); -} - -async function selectRowsInPages(args: { - supabase: ReturnType; - table: "document_chunks" | "document_images"; - select: string; - documentId: string; - searchableOnly?: boolean; -}) { - const rows: T[] = []; - if (args.searchableOnly && args.table !== "document_images") { - throw new Error("searchableOnly reindex paging only supports the document_images table."); - } - for (let offset = 0; ; offset += pageSize) { - const dynamicSupabase = args.supabase as unknown as SupabaseClient; - const query = args.searchableOnly - ? dynamicSupabase - .from("document_images") - .select(args.select) - .eq("document_id", args.documentId) - .eq("searchable", true) - : dynamicSupabase.from(args.table).select(args.select).eq("document_id", args.documentId); - const { data, error } = await query.range(offset, offset + pageSize - 1); - if (error) throw new Error(error.message); - const page = (data ?? []) as T[]; - rows.push(...page); - if (page.length < pageSize) break; - } - return rows; -} - export async function POST(request: Request) { try { if (isDemoMode()) return NextResponse.json({ error: "Bulk reindex is unavailable in demo mode." }, { status: 400 }); @@ -140,46 +70,16 @@ export async function POST(request: Request) { } if (parsed.mode === "enrichment") { - const [chunks, images] = await Promise.all([ - selectRowsInPages({ - supabase, - table: "document_chunks", - select: "id,document_id,page_number,chunk_index,section_heading,content,image_ids,metadata", - documentId: document.id, - }), - selectRowsInPages({ - supabase, - table: "document_images", - select: "id,page_number,caption,image_type,labels,source_kind,clinical_relevance_score,metadata", - documentId: document.id, - searchableOnly: true, - }), - ]); - const committedChunks = committedReindexRows(document, chunks); - const committedImages = committedReindexRows(document, images); - if (!committedChunks.length) throw new Error("Document has no indexed chunks to enrich."); - committedChunks.sort((a, b) => Number(a.chunk_index ?? 0) - Number(b.chunk_index ?? 0)); - committedImages.sort( - (a, b) => Number(b.clinical_relevance_score ?? 0) - Number(a.clinical_relevance_score ?? 0), - ); - const enrichment = await upsertDocumentEnrichment({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - }); - const memory = await upsertDocumentDeepMemory({ - supabase, - document: document as Parameters[0]["document"], - chunks: committedChunks, - images: committedImages, - summary: enrichment.summary.summary, + const { data: queued, error: queueError } = await supabase.rpc("request_indexing_v3_enrichment", { + p_document_id: document.id, + p_owner_id: user.id, }); + if (queueError) throw new Error("Enrichment is already active or could not be queued safely."); results.push({ documentId: document.id, mode: parsed.mode, ok: true, - jobId: `${enrichment.labels.length}:${memory.memoryCards.length}:${memory.indexUnits.length}`, + jobId: queued && typeof queued === "object" && "job_id" in queued ? String(queued.job_id ?? "") : undefined, }); continue; } diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index eb6640817..27c38c423 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -13,17 +13,14 @@ 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(","); @@ -57,7 +54,6 @@ const PUBLIC_LABEL_LIST_COLUMNS = [ "label_type", "source", "confidence", - "metadata", "created_at", "updated_at", ].join(","); diff --git a/src/app/api/setup-status/route.ts b/src/app/api/setup-status/route.ts index 081568601..e9d620a79 100644 --- a/src/app/api/setup-status/route.ts +++ b/src/app/api/setup-status/route.ts @@ -2,6 +2,7 @@ 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 } from "@/lib/supabase/auth"; import { formatSupabaseUnavailableError, isSupabaseUnavailableError, probeSupabaseHealth } from "@/lib/supabase/health"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -162,7 +163,7 @@ async function readSearchSchemaStatus(supabase: AdminClient | null) { if (!supabase) throw new Error("Supabase admin client is unavailable."); const { data, error } = await supabase.rpc("search_schema_health"); if (error) { - return check("search", label, "needs_setup", `Search health RPC is unavailable or failed: ${error.message}`); + return check("search", label, "needs_setup", "Search health checks are temporarily unavailable."); } const health = (data ?? {}) as SearchSchemaHealth; const missing = Array.isArray(health.missing) ? health.missing : []; @@ -418,5 +419,23 @@ export async function GET(request: Request) { return unsafeLocalProjectResponse(identity); } + if (process.env.NODE_ENV === "production") { + try { + await requireAuthenticatedUser(request, createAdminClient()); + } catch (error) { + if (!(error instanceof AuthenticationError)) throw error; + return NextResponse.json( + { + demoMode: isDemoMode(), + checks: [], + indexingActive: false, + pollAfterMs: null, + generatedAt: new Date().toISOString(), + } satisfies SetupStatusPayload, + { headers: { "Cache-Control": "private, no-store" } }, + ); + } + } + return setupStatusResponse(await readSetupStatusPayload()); } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 410715ef5..c8d2a3507 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -109,6 +109,12 @@ export async function POST(request: Request) { ); } + const declaredLength = Number(request.headers.get("content-length")); + const multipartBudget = env.MAX_UPLOAD_MB * 1024 * 1024 + 1024 * 1024; + if (Number.isFinite(declaredLength) && declaredLength > multipartBudget) { + throw new PublicApiError("Upload request is too large.", 413, { code: "payload_too_large" }); + } + const formData = await request.formData().catch((cause) => { throw new PublicApiError("Invalid upload form data.", 400, { code: "invalid_form_data", diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 06b889292..fdb6de5a3 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,3 +1,5 @@ +"use client"; + import Link from "next/link"; import { FileQuestion, Search } from "lucide-react"; import { cn, primaryControl } from "@/components/ui-primitives"; diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index 0b745c16e..82c10edbe 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -337,6 +337,7 @@ export function AccessibleTable({ }) { const dialogId = useId(); const closeButtonRef = useRef(null); + const dialogRef = useRef(null); const restoreFocusRef = useRef(null); const [open, setOpen] = useState(false); const canExpand = useMobileTableExpansion(expandOnMobile); @@ -366,7 +367,30 @@ export function AccessibleTable({ document.body.style.overflow = "hidden"; const focusTimer = window.setTimeout(() => closeButtonRef.current?.focus(), 0); const handleKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); + if (event.key === "Escape") { + event.preventDefault(); + setOpen(false); + return; + } + if (event.key !== "Tab") return; + const focusable = Array.from( + dialogRef.current?.querySelectorAll( + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), summary, [tabindex]:not([tabindex="-1"])', + ) ?? [], + ); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!dialogRef.current?.contains(document.activeElement)) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } }; document.addEventListener("keydown", handleKeyDown); return () => { @@ -467,6 +491,7 @@ export function AccessibleTable({ {dialogOpen ? (
{ - queueMicrotask(() => { - const persisted = loadPersistedAnswerThread(); - if (persisted) { - restoredThreadFromStorageRef.current = true; - setPriorAnswerTurns(persisted.priorTurns); - setLatestAnswerQuery(persisted.latestTurn?.query ?? null); - if (persisted.latestTurn) { - latestAnswerTurnRef.current = persisted.latestTurn; - setAnswer(persisted.latestTurn.answer); - setSources(persisted.latestTurn.sources); - setModeSearchSubmitted(true); - setQuery(""); - const restoredQuery = persisted.latestTurn.query.trim(); - if (restoredQuery) { - autoRunSearchSignatureRef.current = `answer:${restoredQuery}`; - } - } - answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { - const match = /^answer-turn-(\d+)$/.exec(turn.id); - return match ? Math.max(max, Number(match[1])) : max; - }, 0); - setCollapsedTurnIds( - persisted.collapsedTurnIds.length - ? new Set(persisted.collapsedTurnIds) - : new Set(persisted.priorTurns.map((turn) => turn.id)), - ); - } - answerThreadBootstrappedRef.current = true; - setAnswerThreadBootstrapped(true); - }); - }, []); useEffect(() => { if ( !answerThreadBootstrappedRef.current || @@ -884,7 +852,19 @@ export function ClinicalDashboard({ const [apiUnavailable, setApiUnavailable] = useState(false); const [localProjectReady, setLocalProjectReady] = useState(true); const [isOnline, setIsOnline] = useState(true); - const [selectedDocumentIds, setSelectedDocumentIds] = useState([]); + const routedDocumentId = searchParams.get("documentId"); + const scopedDocumentIds = useMemo( + () => + routedDocumentId && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(routedDocumentId) + ? [routedDocumentId] + : [], + [routedDocumentId], + ); + const [selectedDocumentIds, setSelectedDocumentIds] = useState(scopedDocumentIds); + useEffect(() => { + queueMicrotask(() => setSelectedDocumentIds(scopedDocumentIds)); + }, [scopedDocumentIds]); const [copiedAction, setCopiedAction] = useState(null); const [pendingFeedback, setPendingFeedback] = useState(null); const [actionNotice, setActionNotice] = useState<{ tone: "success" | "warning"; message: string } | null>(null); @@ -939,6 +919,56 @@ export function ClinicalDashboard({ const localNoAuthMode = isLocalNoAuthMode(); const explicitDemoMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true"; const clientDemoMode = explicitDemoMode || browserAuthUnavailableDemoFallback || localNoAuthMode; + const answerThreadOwnerId = auth.session?.user.id ?? (clientDemoMode ? "local-demo-session" : null); + const previousAnswerThreadOwnerIdRef = useRef(answerThreadOwnerId); + useEffect(() => { + const previousOwnerId = previousAnswerThreadOwnerIdRef.current; + previousAnswerThreadOwnerIdRef.current = answerThreadOwnerId; + if (!previousOwnerId || previousOwnerId === answerThreadOwnerId) return; + answerThreadBootstrappedRef.current = false; + queueMicrotask(() => { + setPriorAnswerTurns([]); + setLatestAnswerQuery(null); + setCollapsedTurnIds(new Set()); + setAnswer(null); + setSources([]); + latestAnswerTurnRef.current = null; + setAnswerThreadBootstrapped(false); + }); + }, [answerThreadOwnerId]); + useEffect(() => { + if (authStatus === "loading" || answerThreadBootstrappedRef.current) return; + queueMicrotask(() => { + const persisted = answerThreadOwnerId ? loadPersistedAnswerThread(answerThreadOwnerId) : null; + if (persisted) { + restoredThreadFromStorageRef.current = true; + setPriorAnswerTurns(persisted.priorTurns); + setLatestAnswerQuery(persisted.latestTurn?.query ?? null); + if (persisted.latestTurn) { + latestAnswerTurnRef.current = persisted.latestTurn; + setAnswer(persisted.latestTurn.answer); + setSources(persisted.latestTurn.sources); + setModeSearchSubmitted(true); + setQuery(""); + const restoredQuery = persisted.latestTurn.query.trim(); + if (restoredQuery) autoRunSearchSignatureRef.current = `answer:${restoredQuery}`; + } + answerTurnSeqRef.current = persisted.priorTurns.reduce((max, turn) => { + const match = /^answer-turn-(\d+)$/.exec(turn.id); + return match ? Math.max(max, Number(match[1])) : max; + }, 0); + setCollapsedTurnIds( + persisted.collapsedTurnIds.length + ? new Set(persisted.collapsedTurnIds) + : new Set(persisted.priorTurns.map((turn) => turn.id)), + ); + } else if (!answerThreadOwnerId) { + clearPersistedAnswerThread(); + } + answerThreadBootstrappedRef.current = true; + setAnswerThreadBootstrapped(true); + }); + }, [answerThreadOwnerId, authStatus]); const uploadReadOnlyMode = demoMode || process.env.NEXT_PUBLIC_DEMO_MODE === "true" || browserAuthUnavailableDemoFallback; const localDevCanAttemptPrivateApis = process.env.NODE_ENV !== "production" && hasReadyPublicSearchSetup(setupChecks); @@ -1037,10 +1067,16 @@ export function ClinicalDashboard({ }, [prefetchApplications]); useEffect(() => { + if (!answerThreadOwnerId) { + queueMicrotask(() => setRecentQueries([])); + return; + } let cancelled = false; const frame = window.requestAnimationFrame(() => { try { - const stored = JSON.parse(window.localStorage.getItem(recentQueryStorageKey) ?? "[]"); + const stored = JSON.parse( + window.sessionStorage.getItem(`${recentQueryStorageKey}:${answerThreadOwnerId}`) ?? "[]", + ); if (Array.isArray(stored) && !cancelled) { setRecentQueries( stored.filter((item): item is string => typeof item === "string" && Boolean(item.trim())).slice(0, 5), @@ -1054,24 +1090,29 @@ export function ClinicalDashboard({ cancelled = true; window.cancelAnimationFrame(frame); }; - }, []); - - const rememberRecentQuery = useCallback((value: string) => { - const trimmedValue = value.trim(); - if (!trimmedValue) return; - setRecentQueries((current) => { - const next = [trimmedValue, ...current.filter((item) => item.toLowerCase() !== trimmedValue.toLowerCase())].slice( - 0, - 5, - ); - try { - window.localStorage.setItem(recentQueryStorageKey, JSON.stringify(next)); - } catch { - // Recent questions are a convenience only; ignore storage failures. - } - return next; - }); - }, []); + }, [answerThreadOwnerId]); + + const rememberRecentQuery = useCallback( + (value: string) => { + const trimmedValue = value.trim(); + if (!trimmedValue) return; + setRecentQueries((current) => { + const next = [ + trimmedValue, + ...current.filter((item) => item.toLowerCase() !== trimmedValue.toLowerCase()), + ].slice(0, 5); + try { + if (answerThreadOwnerId) { + window.sessionStorage.setItem(`${recentQueryStorageKey}:${answerThreadOwnerId}`, JSON.stringify(next)); + } + } catch { + // Recent questions are a convenience only; ignore storage failures. + } + return next; + }); + }, + [answerThreadOwnerId], + ); useEffect(() => { if (!answerThreadBootstrapped) return; @@ -1080,13 +1121,22 @@ export function ClinicalDashboard({ clearPersistedAnswerThread(); return; } - savePersistedAnswerThread({ + if (!answerThreadOwnerId) return; + savePersistedAnswerThread(answerThreadOwnerId, { version: 1, priorTurns: priorAnswerTurns, latestTurn: latestAnswerTurnRef.current, collapsedTurnIds: [...collapsedTurnIds], }); - }, [searchMode, answer, priorAnswerTurns, collapsedTurnIds, latestAnswerQuery, answerThreadBootstrapped]); + }, [ + searchMode, + answer, + priorAnswerTurns, + collapsedTurnIds, + latestAnswerQuery, + answerThreadBootstrapped, + answerThreadOwnerId, + ]); useEffect(() => { jobsRef.current = jobs; @@ -1133,7 +1183,10 @@ export function ClinicalDashboard({ setLocalProjectReady(true); if (includeSetup) { - const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null); + const setupResponse = await fetch("/api/setup-status", { + cache: "no-store", + headers: authorizationHeader, + }).catch(() => null); if (!setupResponse) { if (isDeployedClinicalKb()) { diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 887cf3ba3..be03dc76a 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2026,7 +2026,7 @@ export function DocumentViewer({ return null; } setLocalProjectReady(true); - return fetch("/api/setup-status"); + return fetch("/api/setup-status", { headers: authorizationHeader }); }) .then((response) => (response?.ok ? response.json() : null)) .then((payload) => { @@ -2036,7 +2036,7 @@ export function DocumentViewer({ return () => { active = false; }; - }, [isConfigured]); + }, [authorizationHeader, isConfigured]); useEffect(() => { if (!canViewSourceDocuments && authStatus === "loading") { @@ -2317,7 +2317,7 @@ export function DocumentViewer({ : (effectiveViewerError ?? "Source unavailable"); const documentHomeHref = "/?mode=documents"; const scopedDocumentHref = readyDocument - ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}` + ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}&documentId=${encodeURIComponent(documentId)}` : documentHomeHref; const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering"; diff --git a/src/components/ServiceDetailPage.tsx b/src/components/ServiceDetailPage.tsx deleted file mode 100644 index c19277a98..000000000 --- a/src/components/ServiceDetailPage.tsx +++ /dev/null @@ -1 +0,0 @@ -export { ServiceDetailPage } from "@/components/services/service-detail-page"; diff --git a/src/components/clinical-dashboard-client.tsx b/src/components/clinical-dashboard-client.tsx deleted file mode 100644 index d9a3bee82..000000000 --- a/src/components/clinical-dashboard-client.tsx +++ /dev/null @@ -1,19 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import type { AppModeId } from "@/lib/app-modes"; - -const ClinicalDashboard = dynamic(() => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), { - ssr: false, -}); - -type ClinicalDashboardClientProps = { - initialSearchMode?: AppModeId; - initialQuery?: string; - focusSearch?: boolean; - autoRunSearch?: boolean; -}; - -export function ClinicalDashboardClient(props: ClinicalDashboardClientProps) { - return ; -} diff --git a/src/components/clinical-dashboard-lazy.tsx b/src/components/clinical-dashboard-lazy.tsx deleted file mode 100644 index 70bd7aafc..000000000 --- a/src/components/clinical-dashboard-lazy.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -// `ssr: false` requires a Client Component in the App Router; this wrapper -// keeps the heavy dashboard bundle browser-only for the server-rendered -// home page. -export const ClinicalDashboardLazy = dynamic( - () => import("@/components/clinical-dashboard").then((m) => m.ClinicalDashboard), - { ssr: false }, -); diff --git a/src/components/clinical-dashboard/favourites-home-page.tsx b/src/components/clinical-dashboard/favourites-home-page.tsx deleted file mode 100644 index fcbf7beb4..000000000 --- a/src/components/clinical-dashboard/favourites-home-page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; - -import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; -import { appModeHomeHref } from "@/lib/app-modes"; -import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; - -type FavouritesHomePageProps = { - query?: string; -}; - -export function FavouritesHomePage({ query = "" }: FavouritesHomePageProps) { - const router = useRouter(); - const [queryOverride, setQueryOverride] = useState<{ source: string; value: string } | null>(null); - const filterQuery = queryOverride?.source === query ? queryOverride.value : query; - - return ( -
-
- { - setQueryOverride({ source: query, value: "" }); - router.push(appModeHomeHref("favourites", { focus: true })); - }} - onAddFavourite={() => undefined} - desktopComposerSlotId={modeHomeDesktopComposerSlotId} - headingLevel={1} - /> -
-
- ); -} diff --git a/src/components/document-search-live-opener.tsx b/src/components/document-search-live-opener.tsx deleted file mode 100644 index 79bc1f918..000000000 --- a/src/components/document-search-live-opener.tsx +++ /dev/null @@ -1,665 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { - AlertCircle, - ArrowLeft, - BadgeCheck, - BookOpen, - ExternalLink, - FileImage, - FileText, - Filter, - Loader2, - Search, - Sparkles, - Table2, - Target, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; - -import { cn } from "@/components/ui-primitives"; -import { useAuthSession } from "@/lib/supabase/client"; - -type DocumentListItem = { - id: string; - title?: string | null; - file_name?: string | null; - status?: string | null; -}; - -type DocumentsPayload = { - documents?: DocumentListItem[]; -}; - -type ChunkSearchResult = { - id: string; - page_number?: number | null; - chunk_index?: number | null; - section_heading?: string | null; - snippet?: string | null; - score?: number | null; -}; - -type ChunkSearchPayload = { - results?: ChunkSearchResult[]; -}; - -type DocumentDetailPayload = { - chunks?: ChunkSearchResult[]; -}; - -type ResolverState = - { status: "opening"; message: string; liveHref?: string } | { status: "mock"; message: string; liveHref?: string }; - -type MockSourceDocument = { - slug: string; - title: string; - fileName: string; - kind: string; - defaultPage: number; - pageCount: number; - status: string; - review: string; - section: string; - summary: string; - tags: string[]; - matchedTerms: string[]; - evidence: Array<{ label: string; value: string; icon: typeof Table2; tone: "success" | "info" | "warning" }>; - passage: string[]; - tableRows: Array<[string, string, string]>; -}; - -const focusRing = - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; - -const defaultQuery = "clozapine monitoring table"; - -const mockSources: MockSourceDocument[] = [ - { - slug: "clozapine-monitoring", - title: "Clozapine physical health monitoring protocol", - fileName: "clozapine-physical-health-monitoring.pdf", - kind: "Protocol", - defaultPage: 12, - pageCount: 18, - status: "Current", - review: "Review 2026", - section: "Blood test monitoring table", - summary: - "Mock source preview for the command-centre handoff. It keeps the exact page, table evidence, and actions visible without requiring private document authentication.", - tags: ["Medication", "Monitoring", "Shared care"], - matchedTerms: ["clozapine", "monitoring", "table"], - evidence: [ - { label: "Table evidence", value: "8 rows", icon: Table2, tone: "success" }, - { label: "PDF page", value: "p.12", icon: FileText, tone: "info" }, - { label: "Review note", value: "2026", icon: AlertCircle, tone: "warning" }, - ], - passage: [ - "Monitoring requirements are grouped by treatment stage and missed-dose interval.", - "Restart and escalation decisions should be checked against the local protocol table.", - "Shared-care transfer requires the monitoring schedule and review responsibility to be visible.", - ], - tableRows: [ - ["Stable treatment", "Continue scheduled FBC/ANC checks", "Routine review"], - ["Missed dose 48-72h", "Restart pathway and monitoring check", "Prescriber review"], - ["Review due", "Confirm local protocol currency", "Document source status"], - ], - }, - { - slug: "acute-agitation-pathway", - title: "Acute agitation clinical pathway", - fileName: "acute-agitation-clinical-pathway.pdf", - kind: "Guideline", - defaultPage: 4, - pageCount: 9, - status: "Current", - review: "Local pathway", - section: "Flowchart and escalation pathway", - summary: - "Mock source preview showing how image and flowchart evidence can stay attached to the selected search result.", - tags: ["Risk", "Escalation", "ED"], - matchedTerms: ["agitation", "pathway", "flowchart"], - evidence: [ - { label: "Image evidence", value: "flowchart", icon: FileImage, tone: "info" }, - { label: "PDF page", value: "p.4", icon: FileText, tone: "success" }, - { label: "Risk pathway", value: "visible", icon: AlertCircle, tone: "warning" }, - ], - passage: [ - "The pathway separates immediate safety steps from medication and senior review prompts.", - "Flowchart evidence remains visible before opening the full source file.", - "Escalation points are grouped so the result can be scoped or used for a follow-up answer.", - ], - tableRows: [ - ["Immediate risk", "Use local safety pathway", "Escalate"], - ["De-escalation", "Document response and triggers", "Review"], - ["Senior input", "Confirm local governance", "Open source"], - ], - }, - { - slug: "mental-health-act-forms", - title: "Mental Health Act forms quick reference", - fileName: "mental-health-act-forms-reference.pdf", - kind: "Quick reference", - defaultPage: 2, - pageCount: 6, - status: "Indexed", - review: "Form checklist", - section: "Forms and documentation", - summary: - "Mock source preview for form-heavy results, keeping the document type and target page obvious from the handoff.", - tags: ["Forms", "Workflow", "Legal"], - matchedTerms: ["forms", "workflow", "reference"], - evidence: [ - { label: "Checklist", value: "forms", icon: BadgeCheck, tone: "success" }, - { label: "PDF page", value: "p.2", icon: FileText, tone: "info" }, - { label: "Workflow", value: "legal", icon: AlertCircle, tone: "warning" }, - ], - passage: [ - "The quick reference groups forms by use case and required documentation step.", - "The handoff preserves the target page so users can inspect the original source quickly.", - "Scope and answer actions remain available from the selected source preview.", - ], - tableRows: [ - ["Assessment", "Open form checklist", "Confirm status"], - ["Transfer", "Check required document", "Open source"], - ["Review", "Record local governance", "Scope"], - ], - }, -]; - -async function fetchJson(url: string, signal: AbortSignal, authorizationHeader: Record): Promise { - const response = await fetch(url, { - cache: "no-store", - headers: { Accept: "application/json", ...authorizationHeader }, - signal, - }); - if (!response.ok) { - throw new Error(`Request failed with ${response.status}.`); - } - return (await response.json()) as T; -} - -function pageFor(result: ChunkSearchResult | undefined) { - return Math.max(1, Number(result?.page_number ?? 1)); -} - -function documentSearchTerm(query: string, documentHint: string) { - const lowered = `${documentHint} ${query}`.toLowerCase(); - if (lowered.includes("clozapine")) return "clozapine"; - if (lowered.includes("agitation")) return "agitation"; - if (lowered.includes("mental health act")) return "mental health act"; - return query.split(/\s+/).slice(0, 3).join(" ") || defaultQuery; -} - -function liveDocumentHref(documentId: string, result: ChunkSearchResult | undefined) { - const params = new URLSearchParams({ page: String(pageFor(result)) }); - if (result?.id) params.set("chunk", result.id); - return `/documents/${documentId}?${params.toString()}`; -} - -function numberParam(value: string | null, fallback: number) { - const parsed = Number(value); - return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; -} - -function mockSourceFor(documentHint: string, query: string) { - const normalized = `${documentHint} ${query}`.toLowerCase(); - return ( - mockSources.find((source) => normalized.includes(source.slug) || normalized.includes(source.title.toLowerCase())) ?? - (normalized.includes("agitation") - ? mockSources.find((source) => source.slug === "acute-agitation-pathway") - : null) ?? - (normalized.includes("mental health act") || normalized.includes("forms") - ? mockSources.find((source) => source.slug === "mental-health-act-forms") - : null) ?? - mockSources[0] - ); -} - -function TonePill({ - children, - tone = "neutral", -}: { - children: React.ReactNode; - tone?: "accent" | "info" | "success" | "warning" | "neutral"; -}) { - const toneClass = - tone === "accent" - ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" - : tone === "info" - ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" - : tone === "success" - ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" - : tone === "warning" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" - : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; - return ( - - {children} - - ); -} - -function EvidenceCard({ label, value, icon: Icon, tone }: MockSourceDocument["evidence"][number]) { - const toneClass = - tone === "success" - ? "border-[color:var(--success-border)] bg-[color:var(--success-soft)] text-[color:var(--success)]" - : tone === "warning" - ? "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]" - : "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]"; - return ( -
- - -

{label}

-

{value}

-
- ); -} - -function MockDocumentPagePreview({ source, page, chunk }: { source: MockSourceDocument; page: number; chunk: string }) { - return ( -
-
-
-

- Mock source page -

-

{source.section}

-
-
- p.{page} - {chunk.replaceAll("-", " ")} -
-
-
-
-
- - - -
-
-

{source.passage[0]}

-
-
- {source.passage.slice(1).map((line) => ( -

- {line} -

- ))} -
-
- - - - - - - - - - {source.tableRows.map((row, index) => ( - - {row.map((cell) => ( - - ))} - - ))} - -
Source rowWhat to reviewAction
- {cell} -
-
-
- -
-
- ); -} - -function MockSourceWorkbench({ - source, - page, - chunk, - query, - message, - liveHref, -}: { - source: MockSourceDocument; - page: number; - chunk: string; - query: string; - message: string; - liveHref?: string; -}) { - return ( -
-
-
- - -
-

- Mock source preview -

-

- {source.title} -

-

{message}

-
- {liveHref ? ( - -
-
- -
-
- -
- {source.evidence.map((item) => ( - - ))} -
-
- - -
-
- ); -} - -export function DocumentSearchLiveOpener() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { authorizationHeader, status: authStatus } = useAuthSession(); - const query = searchParams.get("q")?.trim() || defaultQuery; - const documentHint = searchParams.get("document")?.trim() || "clozapine"; - const mockSource = useMemo(() => mockSourceFor(documentHint, query), [documentHint, query]); - const requestedPage = numberParam(searchParams.get("page"), mockSource.defaultPage); - const chunk = searchParams.get("chunk")?.trim() || "best-match"; - const [state, setState] = useState({ - status: "opening", - message: "Finding an indexed document and matching source chunk.", - }); - - const lookupTerm = useMemo(() => documentSearchTerm(query, documentHint), [documentHint, query]); - - useEffect(() => { - const controller = new AbortController(); - - async function openLiveDocument() { - if (authStatus === "loading") { - setState({ status: "opening", message: "Checking browser document access." }); - return; - } - - try { - setState({ status: "opening", message: "Finding a real indexed document." }); - const documentParams = new URLSearchParams({ - limit: "20", - includeMeta: "false", - status: "indexed", - q: lookupTerm, - }); - let payload = await fetchJson( - `/api/documents?${documentParams.toString()}`, - controller.signal, - authorizationHeader, - ); - let documents = (payload.documents ?? []).filter((document) => document.status === "indexed"); - - if (documents.length === 0) { - const fallbackParams = new URLSearchParams({ limit: "20", includeMeta: "false", status: "indexed" }); - payload = await fetchJson( - `/api/documents?${fallbackParams.toString()}`, - controller.signal, - authorizationHeader, - ); - documents = (payload.documents ?? []).filter((document) => document.status === "indexed"); - } - - if (documents.length === 0) { - setState({ - status: "mock", - message: - "No indexed live document was available for this lookup. This mock preview shows the intended source handoff.", - }); - return; - } - - setState({ status: "opening", message: "Selecting the best matching chunk." }); - let best: { document: DocumentListItem; result?: ChunkSearchResult; score: number } | null = null; - - for (const document of documents.slice(0, 8)) { - const chunkParams = new URLSearchParams({ q: query, limit: "1" }); - const searchPayload = await fetchJson( - `/api/documents/${document.id}/search?${chunkParams.toString()}`, - controller.signal, - authorizationHeader, - ); - const result = searchPayload.results?.[0]; - const score = Number(result?.score ?? 0); - if (result && (!best || score > best.score)) { - best = { document, result, score }; - } - } - - if (!best) { - const document = documents[0]; - const detailPayload = await fetchJson( - `/api/documents/${document.id}?page=1&pageLimit=1&chunkLimit=1`, - controller.signal, - authorizationHeader, - ); - best = { document, result: detailPayload.chunks?.[0], score: 0 }; - } - - const liveHref = liveDocumentHref(best.document.id, best.result); - setState({ - status: "opening", - message: `Opening ${best.document.title ?? best.document.file_name ?? "document"} in the live viewer.`, - liveHref, - }); - router.replace(liveHref); - } catch (error) { - if (controller.signal.aborted) return; - setState({ - status: "mock", - message: - error instanceof Error - ? `${error.message} Showing the mock source preview instead.` - : "The live document could not be opened. Showing the mock source preview instead.", - }); - } - } - - void openLiveDocument(); - return () => controller.abort(); - }, [authStatus, authorizationHeader, lookupTerm, query, router]); - - return ( -
-
- -
-
- ); -} diff --git a/src/components/document-viewer-client.tsx b/src/components/document-viewer-client.tsx deleted file mode 100644 index bfbbb86c2..000000000 --- a/src/components/document-viewer-client.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; - -const DocumentViewer = dynamic(() => import("@/components/DocumentViewer").then((m) => m.DocumentViewer), { - ssr: false, -}); - -type DocumentViewerClientProps = { - documentId: string; - initialPage: number; - chunkId?: string; -}; - -export function DocumentViewerClient(props: DocumentViewerClientProps) { - return ; -} diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index 419ba464a..9026f722d 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -325,9 +325,14 @@ function PathwayContextCard({
) : null} - @@ -384,11 +389,11 @@ function ActionPanel({
{hrefForCall ? ( @@ -475,7 +480,10 @@ export function FormDetailPage({ form }: { form: FormRecord }) { try { const current = readSavedRegistrySlugs(savedFormsStorageKey); const next = current.includes(form.slug) ? current.filter((item) => item !== form.slug) : [form.slug, ...current]; - writeSavedRegistrySlugs(savedFormsStorageKey, next); + if (!writeSavedRegistrySlugs(savedFormsStorageKey, next)) { + setNotice("Save failed"); + return; + } const nowSaved = next.includes(form.slug); setSaved(nowSaved); setNotice(nowSaved ? "Form saved" : "Form removed from saved items"); diff --git a/src/components/mode-home-page-skeleton.tsx b/src/components/mode-home-page-skeleton.tsx index da198de22..aab04e635 100644 --- a/src/components/mode-home-page-skeleton.tsx +++ b/src/components/mode-home-page-skeleton.tsx @@ -1,9 +1,7 @@ -import { cn } from "@/components/ui-primitives"; - function SkeletonBlock({ className }: { className?: string }) { return ( ); diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 36d7074ea..0e026ff2f 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -483,7 +483,10 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const next = current.includes(service.slug) ? current.filter((item) => item !== service.slug) : [service.slug, ...current]; - writeSavedRegistrySlugs(savedServicesStorageKey, next); + if (!writeSavedRegistrySlugs(savedServicesStorageKey, next)) { + setNotice("Save failed"); + return; + } const nowSaved = next.includes(service.slug); setSaved(nowSaved); setNotice(nowSaved ? "Service saved" : "Service removed from saved items"); diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 6fe93282f..f2ea64c19 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -422,6 +422,8 @@ function RightRail({ @@ -565,6 +567,8 @@ export function ServicesNavigatorPage() { className="inline-flex min-h-10 w-10 items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2 text-sm font-bold text-[color:var(--text-heading)] shadow-[var(--shadow-tight)] transition hover:border-[color:var(--clinical-accent-border)] hover:bg-[color:var(--clinical-accent-soft)] sm:min-h-11 sm:w-auto sm:px-4" type="button" aria-label="Open service filters" + disabled + title="Advanced filters are not available yet" > Filters @@ -572,6 +576,8 @@ export function ServicesNavigatorPage() { diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index 213dc6424..0798d35ea 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -77,10 +77,15 @@ export function Sheet({ }) { const panelRef = useRef(null); const closeRef = useRef(null); + const onCloseRef = useRef(onClose); const dragRef = useRef<{ startY: number; dragging: boolean }>({ startY: 0, dragging: false }); const titleId = useId(); const descId = useId(); + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + // Swipe-to-dismiss for the mobile bottom sheet: dragging the grip down past a // threshold closes the sheet; a shorter drag snaps back. Grip-initiated only, // so it never competes with scrolling the sheet body. Keyboard/backdrop/close @@ -133,7 +138,7 @@ export function Sheet({ function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); - onClose(); + onCloseRef.current(); return; } if (event.key !== "Tab") return; @@ -179,7 +184,7 @@ export function Sheet({ }, 50); }); }; - }, [open, onClose, initialFocusRef, returnFocusRef]); + }, [open, initialFocusRef, returnFocusRef]); if (!open) return null; diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index ed8e43576..7cca190b0 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -144,7 +144,7 @@ function deriveTrust(answer: RagAnswer): AnswerRenderTrust { function sourceStrengthFor(candidate: SourceCandidate) { if (candidate.sourceStrength) return candidate.sourceStrength; - return candidate.citation.source_metadata?.document_status === "current" ? "strong" : "none"; + return "none"; } function sourceLinkFromCandidate(candidate: SourceCandidate): SourceLink { @@ -229,12 +229,20 @@ function candidateFromCoreSourceLink(link: CoreSourceLink, triggerField: string) function collectSourceCandidates(answer: RagAnswer, sources: SearchResult[]) { const candidates: SourceCandidate[] = []; + const supportingChunkIds = new Set([ + ...(answer.citations ?? []).map((citation) => citation.chunk_id), + ...(answer.quoteCards ?? answer.smartPanel?.quotes ?? []).map((quote) => quote.chunk_id), + ...(answer.answerSections ?? []).flatMap((section) => section.citation_chunk_ids ?? []), + ...(answer.smartApiPlan?.coreSourceLinks ?? []).map((link) => link.chunk_id).filter(Boolean), + ]); for (const link of answer.smartApiPlan?.coreSourceLinks ?? []) { const candidate = candidateFromCoreSourceLink(link, "smartApiPlan.coreSourceLinks"); if (candidate) candidates.push(candidate); } const bestSource = answer.bestSource ?? answer.smartPanel?.bestSource ?? null; - if (bestSource) candidates.push(candidateFromBestSource(bestSource, "bestSource")); + if (bestSource && supportingChunkIds.has(bestSource.chunk_id)) { + candidates.push(candidateFromBestSource(bestSource, "bestSource")); + } for (const citation of answer.citations ?? []) candidates.push(candidateFromCitation(citation, "citations")); for (const quote of answer.quoteCards ?? answer.smartPanel?.quotes ?? []) { candidates.push({ @@ -244,7 +252,9 @@ function collectSourceCandidates(answer: RagAnswer, sources: SearchResult[]) { sourceStrength: quote.source_strength, }); } - for (const source of sources) candidates.push(candidateFromSearchResult(source, "sources")); + for (const source of sources) { + if (supportingChunkIds.has(source.id)) candidates.push(candidateFromSearchResult(source, "sources")); + } const sourceById = new Map(sources.map((source) => [source.id, source])); for (const section of answer.answerSections ?? []) { diff --git a/src/lib/answer-telemetry.ts b/src/lib/answer-telemetry.ts index 9df176fdc..49fc683fa 100644 --- a/src/lib/answer-telemetry.ts +++ b/src/lib/answer-telemetry.ts @@ -128,23 +128,22 @@ export function buildAnswerLogRow(args: { query: string; ownerId?: string | null // detached and its error swallowed (with a throttled warning). let answerLogFailureCount = 0; -export function logAnswerDiagnostics(args: { +export async function logAnswerDiagnostics(args: { supabase: ReturnType; query: string; ownerId?: string | null; answer: AnswerTelemetrySource; }) { - void (async () => { - try { - await args.supabase.from("rag_retrieval_logs").insert(buildAnswerLogRow(args)); - } catch (error) { - answerLogFailureCount += 1; - if (answerLogFailureCount <= 3 || answerLogFailureCount % 25 === 0) { - console.warn("rag_retrieval_logs answer insert failed", { - failures: answerLogFailureCount, - message: error instanceof Error ? error.message : "unknown answer logging error", - }); - } + try { + const { error } = await args.supabase.from("rag_retrieval_logs").insert(buildAnswerLogRow(args)); + if (error) throw error; + } catch (error) { + answerLogFailureCount += 1; + if (answerLogFailureCount <= 3 || answerLogFailureCount % 25 === 0) { + console.warn("rag_retrieval_logs answer insert failed", { + failures: answerLogFailureCount, + message: error instanceof Error ? error.message : "unknown answer logging error", + }); } - })(); + } } diff --git a/src/lib/answer-thread-storage.ts b/src/lib/answer-thread-storage.ts index d2aa683b4..2a0ac1d0e 100644 --- a/src/lib/answer-thread-storage.ts +++ b/src/lib/answer-thread-storage.ts @@ -66,10 +66,14 @@ function normalizePersistedAnswerThread(value: unknown): PersistedAnswerThread | }; } -export function loadPersistedAnswerThread(): PersistedAnswerThread | null { - if (typeof window === "undefined") return null; +function scopedStorageKey(ownerId: string) { + return `${answerThreadStorageKey}:${ownerId}`; +} + +export function loadPersistedAnswerThread(ownerId: string): PersistedAnswerThread | null { + if (typeof window === "undefined" || !ownerId) return null; try { - const raw = window.localStorage.getItem(answerThreadStorageKey); + const raw = window.sessionStorage.getItem(scopedStorageKey(ownerId)); if (!raw) return null; return normalizePersistedAnswerThread(JSON.parse(raw)); } catch { @@ -77,8 +81,8 @@ export function loadPersistedAnswerThread(): PersistedAnswerThread | null { } } -export function savePersistedAnswerThread(thread: PersistedAnswerThread): boolean { - if (typeof window === "undefined") return false; +export function savePersistedAnswerThread(ownerId: string, thread: PersistedAnswerThread): boolean { + if (typeof window === "undefined" || !ownerId) return false; try { const payload: PersistedAnswerThread = { version: 1, @@ -88,7 +92,7 @@ export function savePersistedAnswerThread(thread: PersistedAnswerThread): boolea }; const serialized = JSON.stringify(payload); if (serialized.length > maxStorageBytes) return false; - window.localStorage.setItem(answerThreadStorageKey, serialized); + window.sessionStorage.setItem(scopedStorageKey(ownerId), serialized); return true; } catch { return false; @@ -99,6 +103,12 @@ export function clearPersistedAnswerThread() { if (typeof window === "undefined") return; try { window.localStorage.removeItem(answerThreadStorageKey); + for (let index = window.sessionStorage.length - 1; index >= 0; index -= 1) { + const key = window.sessionStorage.key(index); + if (key === answerThreadStorageKey || key?.startsWith(`${answerThreadStorageKey}:`)) { + window.sessionStorage.removeItem(key); + } + } } catch { // Thread persistence is a convenience only. } diff --git a/src/lib/answer-verification.ts b/src/lib/answer-verification.ts index d094269e8..3d3c07876 100644 --- a/src/lib/answer-verification.ts +++ b/src/lib/answer-verification.ts @@ -33,7 +33,7 @@ import type { // a trailing \b after it can never match (it would require a word char to its // right), which previously dropped every percentage token. const NUMERIC_TOKEN_PATTERN = - /\b\d+(?:[.,]\d+)?(?:\s*[-–—]\s*\d+(?:[.,]\d+)?)?\s*(?:×10\^?\d*\/?l?|x10\^?\d*\/?l?|mg\/(?:day|kg|m2|dose)?|mg|mcg|microgram(?:s)?|micrograms?|μg|g\b|kg|ml|mL|l\b|mmol\/l|mmol\/L|mmol|mol\/l|umol\/l|µmol\/l|ng\/ml|units?\/?\w*|iu\b|hours?|hrs?|h\b|days?|weeks?|wk\b|months?|minutes?|mins?|years?|°c|mmhg|bpm)\b|\b\d+(?:[.,]\d+)?\s*%/giu; + /\b\d+\s*:\s*\d+\b|\b\d+(?:[.,]\d+)?(?:\s*[-–—]\s*\d+(?:[.,]\d+)?)?\s*(?:×10\^?\d*\/?l?|x10\^?\d*\/?l?|mg\/(?:day|hour|hr|h|kg|m2|dose)|mg|mcg|ug|microgram(?:s)?|micrograms?|μg|g\b|kg|ml\/(?:day|hour|hr|h)|ml|mL|l\b|mmol\/l|mmol\/L|mmol|mol\/l|umol\/l|µmol\/l|ng\/ml|units?\/?\w*|iu\b|hours?|hrs?|h\b|days?|weeks?|wk\b|months?|minutes?|mins?|years?|°c|mmhg|bpm)\b|\b\d+(?:[.,]\d+)?\s*%/giu; // Decimal numbers and ranges that, while not unit-bearing, are very likely // clinical thresholds in context (e.g. "ANC 2.0", "INR 2-3"). We only treat a @@ -69,6 +69,7 @@ function normalizeNumericToken(raw: string): string { .replace(/[–—]/g, "-") .replace(/,/g, ".") .replace(/µ/g, "μ") + .replace(/ug\b/g, "mcg") .replace(/×10\^?(\d+)/g, "x10^$1") .replace(/x10(\d)/g, "x10^$1") .trim(); @@ -177,8 +178,11 @@ export function verifyAnswerNumbers( return { answerTokens, unverifiedTokens: answerTokens, hasUnverifiedNumbers: answerTokens.length > 0 }; } - const sourceTokens = sourceNumericTokenSet(citedResults); - const unverifiedTokens = answerTokens.filter((token) => !sourceTokens.has(token)); + // Top-level citations do not identify which sentence each citation supports. + // Require a numeric claim to be present in every cited chunk rather than + // allowing an unrelated citation to verify a number by union membership. + const sourceTokenSets = citedResults.map((result) => sourceNumericTokenSet([result])); + const unverifiedTokens = answerTokens.filter((token) => !sourceTokenSets.every((tokens) => tokens.has(token))); return { answerTokens, diff --git a/src/lib/env.ts b/src/lib/env.ts index ae308cd6a..328278625 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -118,7 +118,7 @@ const envSchema = z.object({ RAG_QUERY_HASH_SECRET: z.string().min(16).optional(), SUPABASE_DOCUMENT_BUCKET: z.string().default("clinical-documents"), SUPABASE_IMAGE_BUCKET: z.string().default("clinical-images"), - MAX_UPLOAD_MB: z.coerce.number().int().positive().default(150), + MAX_UPLOAD_MB: z.coerce.number().int().positive().max(150).default(150), MAX_IMPORT_JOBS_PER_RUN: z.coerce.number().int().positive().default(5), MAX_IMPORT_BYTES_PER_RUN: z.coerce.number().int().positive().default(157286400), CHUNK_SIZE: z.coerce.number().int().positive().default(2000), diff --git a/src/lib/evidence.ts b/src/lib/evidence.ts index 80d0317e3..d705eecc3 100644 --- a/src/lib/evidence.ts +++ b/src/lib/evidence.ts @@ -293,13 +293,17 @@ export function buildSmartPanel( export function selectBestSourceRecommendation( results: SearchResult[], - quoteCards: QuoteCard[] = [], + quoteCards?: QuoteCard[], ): BestSourceRecommendation | null { if (results.length === 0) return null; - const candidates = results.some((result) => result.relevance?.isSourceBacked) - ? results.filter((result) => result.relevance?.isSourceBacked) - : results; + const quotedChunkIds = new Set((quoteCards ?? []).map((quote) => quote.chunk_id)); + if (quoteCards && quotedChunkIds.size === 0) return null; + + const quoteSupportedResults = quoteCards ? results.filter((result) => quotedChunkIds.has(result.id)) : results; + const candidates = quoteSupportedResults.some((result) => result.relevance?.isSourceBacked) + ? quoteSupportedResults.filter((result) => result.relevance?.isSourceBacked) + : quoteSupportedResults; let best = candidates[0]; for (const result of candidates.slice(1)) { const bestScore = best.hybrid_score ?? best.similarity; @@ -309,8 +313,8 @@ export function selectBestSourceRecommendation( } } - const directQuote = quoteCards.find((quote) => quote.chunk_id === best.id); - const documentQuote = quoteCards.find((quote) => quote.document_id === best.document_id); + const directQuote = quoteCards?.find((quote) => quote.chunk_id === best.id); + const documentQuote = quoteCards?.find((quote) => quote.document_id === best.document_id); const quote = directQuote?.quote ?? documentQuote?.quote; // Track truncation by the pre-slice length rather than guessing from the // post-trim snippet length (=== 260 dropped the ellipsis when trim shortened a diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index ebdfe86ab..67b316f4a 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -244,6 +244,21 @@ async function extractXlsx(buffer: Buffer) { return { pages, images: [] } satisfies ExtractedDocument; } +export async function assertOoxmlArchiveBudget(buffer: Buffer) { + const zip = await JSZip.loadAsync(buffer); + const entries = Object.values(zip.files); + if (entries.length > 10_000) throw new Error("OOXML archive contains too many entries."); + const expandedBytes = entries.reduce((total, file) => { + const data = (file as unknown as { _data?: { uncompressedSize?: number } })._data; + return total + Math.max(0, Number(data?.uncompressedSize ?? 0)); + }, 0); + const maxExpandedBytes = 512 * 1024 * 1024; + const maxRatioBytes = Math.max(buffer.byteLength * 100, 16 * 1024 * 1024); + if (expandedBytes > maxExpandedBytes || expandedBytes > maxRatioBytes) { + throw new Error("OOXML archive exceeds the safe expanded-size budget."); + } +} + function extractTxt(buffer: Buffer) { return { pages: [{ pageNumber: 1, text: buffer.toString("utf8"), ocrUsed: false }], @@ -260,6 +275,7 @@ export async function extractDocument(args: { buffer: Buffer; fileName: string; args.mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || args.fileName.toLowerCase().endsWith(".docx") ) { + await assertOoxmlArchiveBudget(args.buffer); return extractDocx(args.buffer); } @@ -267,6 +283,7 @@ export async function extractDocument(args: { buffer: Buffer; fileName: string; args.mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || args.fileName.toLowerCase().endsWith(".xlsx") ) { + await assertOoxmlArchiveBudget(args.buffer); return extractXlsx(args.buffer); } diff --git a/src/lib/rag-cache.ts b/src/lib/rag-cache.ts index 748ecd118..b608b4e44 100644 --- a/src/lib/rag-cache.ts +++ b/src/lib/rag-cache.ts @@ -221,11 +221,13 @@ export async function setCachedSearch( results: SearchResult[], telemetry: SearchTelemetry, queryVariants: string[] = [], + options?: { indexingVersionAtRetrievalStart?: string | null }, ): Promise { if (args.skipCache || env.RAG_SEARCH_CACHE_TTL_MS <= 0 || env.RAG_SEARCH_CACHE_SIZE <= 0) return; const cacheTelemetry = normalizeCacheStorageTelemetry(telemetry); - const indexingVersion = await cacheIndexingVersion(args); + const indexingVersion = await cacheIndexingVersion(args, { forceRefresh: true }); + if (options?.indexingVersionAtRetrievalStart && indexingVersion !== options.indexingVersionAtRetrievalStart) return; const key = scopedSearchCacheKey(args, telemetry.query_class, queryVariants); searchCache.set(key, { expiresAt: Date.now() + env.RAG_SEARCH_CACHE_TTL_MS, @@ -275,8 +277,12 @@ function sharedCacheSelector( return query; } -export async function cacheIndexingVersion(args: Pick) { +export async function cacheIndexingVersion( + args: Pick, + options?: { forceRefresh?: boolean }, +) { const cacheKey = cacheIndexingVersionCacheKey(args); + if (options?.forceRefresh) cacheIndexingVersionCache.delete(cacheKey); const cached = readExpiringCacheEntry(cacheIndexingVersionCache, cacheKey); if (cached) return cached.value; diff --git a/src/lib/rag-quote-verification.ts b/src/lib/rag-quote-verification.ts index c8bd37546..6cb8e029c 100644 --- a/src/lib/rag-quote-verification.ts +++ b/src/lib/rag-quote-verification.ts @@ -100,8 +100,9 @@ export function enrichGroundedReviewCitations(answer: RagAnswer, results: Search if ((answer.unverifiedNumericTokens?.length ?? 0) > 0 || answer.faithfulnessWarning) return answer; const existing = new Set(answer.citations.map((citation) => citation.chunk_id)); + const verifiedQuoteIds = new Set((answer.quoteCards ?? []).map((quote) => quote.chunk_id)); const additional = compactCitations(results) - .filter((citation) => !existing.has(citation.chunk_id)) + .filter((citation) => verifiedQuoteIds.has(citation.chunk_id) && !existing.has(citation.chunk_id)) .slice(0, minCitations - answer.citations.length); if (additional.length === 0) return answer; diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 043d81e7d..e5c4513ae 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1302,6 +1302,9 @@ async function insertRagQuery(row: RagQueryInsert) { const safeRow = { ...row, query: queryTextForStorage(rawQuery), + // Derived answers can repeat PHI even when the input query is redacted. + // Keep operational metadata and source IDs, but do not retain answer prose. + answer: null, metadata: { ...(row.metadata ?? {}), ...queryPrivacyMetadata(rawQuery) } as Json, }; await supabase.from("rag_queries").insert(safeRow); @@ -2880,6 +2883,7 @@ function shouldUseMemoryBeforeFastPath(queryClass: RagQueryClass) { export async function searchChunksWithTelemetry(args: SearchChunksArgs) { assertGlobalSearchAllowed(args); throwIfAborted(args.signal); + const indexingVersionAtRetrievalStart = await cacheIndexingVersion(args, { forceRefresh: true }); const supabase = createAdminClient(); // 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. @@ -2975,7 +2979,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { if (cached) return cached; const sharedCached = await getSharedCachedSearch(args, queryClassification.queryClass, queryVariants); if (sharedCached?.kind === "hit") { - await setCachedSearch(args, sharedCached.results, sharedCached.telemetry, queryVariants); + await setCachedSearch(args, sharedCached.results, sharedCached.telemetry, queryVariants, { + indexingVersionAtRetrievalStart, + }); return { results: sharedCached.results, telemetry: sharedCached.telemetry }; } if (sharedCached?.kind === "miss") { @@ -3006,7 +3012,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.embedding_skip_reason = "unsupported_short_circuit"; telemetry.retrieval_strategy = "unsupported_short_circuit"; recordSearchScoreTelemetry(telemetry, []); - await setCachedSearch(args, [], telemetry, queryVariants); + await setCachedSearch(args, [], telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: [] as SearchResult[], telemetry }; } @@ -3072,7 +3078,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { markEmbeddingSkippedByTextFastPath(telemetry, baseTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); - await setCachedSearch(args, textFastResults, telemetry, queryVariants); + await setCachedSearch(args, textFastResults, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: textFastResults, telemetry }; } @@ -3115,7 +3121,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { markEmbeddingSkippedByTextFastPath(telemetry, boostedTextFastPath.reason); telemetry.retrieval_strategy = "text_fast_path"; recordSearchScoreTelemetry(telemetry, textFastResults); - await setCachedSearch(args, textFastResults, telemetry, queryVariants); + await setCachedSearch(args, textFastResults, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: textFastResults, telemetry }; } } @@ -3224,7 +3230,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { ); telemetry.retrieval_strategy = "document_lookup_fast_path"; recordSearchScoreTelemetry(telemetry, documentLookupResults); - await setCachedSearch(args, documentLookupResults, telemetry, queryVariants); + await setCachedSearch(args, documentLookupResults, telemetry, queryVariants, { + indexingVersionAtRetrievalStart, + }); return { results: documentLookupResults, telemetry }; } textFastResults = mergeSearchResults(documentLookupResults, textFastResults); @@ -3248,7 +3256,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { if (!args.forceEmbedding && coverageGate.accepted) { telemetry.retrieval_strategy = coverageGate.strategy; recordSearchScoreTelemetry(telemetry, coverageGateResults); - await setCachedSearch(args, coverageGateResults, telemetry, queryVariants); + await setCachedSearch(args, coverageGateResults, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results: coverageGateResults, telemetry }; } textFastResults = mergeSearchResults(coverageGateResults, textFastResults); @@ -3425,7 +3433,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; telemetry.retrieval_strategy = "hybrid"; recordSearchScoreTelemetry(telemetry, results); - await setCachedSearch(args, results, telemetry, queryVariants); + await setCachedSearch(args, results, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results, telemetry }; } @@ -3507,7 +3515,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) { telemetry.rerank_latency_ms += Date.now() - rerankStartedAt; telemetry.retrieval_strategy = "vector_fallback"; recordSearchScoreTelemetry(telemetry, results); - await setCachedSearch(args, results, telemetry, queryVariants); + await setCachedSearch(args, results, telemetry, queryVariants, { indexingVersionAtRetrievalStart }); return { results, telemetry }; } @@ -4757,6 +4765,7 @@ ${qualityRetryInstruction}` await setCachedAnswer(args, answer, { indexingVersionAtRetrievalStart }); return answer; } catch (error) { + if ((error instanceof DOMException && error.name === "AbortError") || args.signal?.aborted) throw error; const relatedDocuments = await relatedDocumentsPromise; await args.onProgress?.({ stage: "finalizing", diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index e8d2f8797..7612f8ef5 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2185,6 +2185,10 @@ export type Database = { }; invoke_indexing_v3_agent: { Args: { p_limit?: number }; Returns: number }; invoke_ingestion_worker: { Args: { p_limit?: number }; Returns: number }; + request_indexing_v3_enrichment: { + Args: { p_document_id: string; p_owner_id: string }; + Returns: { job_id?: string; ok?: boolean }; + }; is_committed_artifact_generation: { Args: { artifact_metadata: Json; document_metadata: Json }; Returns: boolean; diff --git a/src/lib/validation/body.ts b/src/lib/validation/body.ts index f7a3bad5a..0fd4f7dd8 100644 --- a/src/lib/validation/body.ts +++ b/src/lib/validation/body.ts @@ -1,12 +1,53 @@ import { z } from "zod"; +import { PublicApiError } from "@/lib/http"; import { parseSchema } from "@/lib/validation/http"; +export const defaultJsonBodyLimitBytes = 256 * 1024; + +async function readBoundedJson(request: Request, maxBytes = defaultJsonBodyLimitBytes): Promise { + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new PublicApiError("Request body is too large.", 413, { code: "payload_too_large" }); + } + + if (!request.body) return null; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel("payload_too_large"); + throw new PublicApiError("Request body is too large.", 413, { code: "payload_too_large" }); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } +} + export async function parseJsonBody( request: Request, schema: TSchema, message = "Invalid request body.", ): Promise> { - const body = await request.json().catch(() => null); + const body = await readBoundedJson(request); return parseSchema(schema, body, message, "invalid_body"); } @@ -15,7 +56,10 @@ export async function parseJsonBodyOrDefault( schema: TSchema, fallback: z.infer, ): Promise> { - const body = await request.json().catch(() => undefined); + const body = await readBoundedJson(request).catch((error) => { + if (error instanceof PublicApiError) throw error; + return undefined; + }); const parsed = schema.safeParse(body); return parsed.success ? parsed.data : fallback; } diff --git a/src/proxy.ts b/src/proxy.ts index 0b80dff55..16fe19646 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -40,6 +40,20 @@ export async function proxy(request: NextRequest) { const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); const csp = buildContentSecurityPolicy({ isDevelopment, isLocalHttpRuntime, nonce }); + if (request.nextUrl.pathname === "/api/upload") { + const declaredLength = Number(request.headers.get("content-length")); + const uploadEnvelopeBytes = env.MAX_UPLOAD_MB * 1024 * 1024 + 1024 * 1024; + if (Number.isFinite(declaredLength) && declaredLength > uploadEnvelopeBytes) { + const response = NextResponse.json( + { error: "Upload request is too large.", code: "payload_too_large" }, + { status: 413 }, + ); + response.headers.set("content-security-policy", csp); + response.headers.set("cache-control", "private, no-store"); + return response; + } + } + // Request headers Next.js reads during SSR: `x-nonce` for our own inline //