diff --git a/.env.example b/.env.example index 62cee8871..8892fc581 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Supabase project values for the live "Clinical KB Database" project. +# Supabase project values for the live "Clinical KB Database" project. # Keep service role keys server-only; never prefix them with NEXT_PUBLIC_ or # import them into client components. NEXT_PUBLIC_SUPABASE_URL=https://sjrfecxgysukkwxsowpy.supabase.co @@ -6,9 +6,14 @@ SUPABASE_PROJECT_REF=sjrfecxgysukkwxsowpy SUPABASE_PROJECT_NAME=Clinical KB Database NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your-publishable-or-anon-key SUPABASE_SERVICE_ROLE_KEY=your-service-role-key +# Direct Postgres connection string for scripts/migrations that need SQL access. +# Server-only; never expose in client code or NEXT_PUBLIC_ vars. +SUPABASE_DB_URL=postgresql://postgres:password@db.sjrfecxgysukkwxsowpy.supabase.co:5432/postgres # Edge Function only (indexing-v3-agent cron auth). Not read by Next.js env.ts. # Set in Supabase Edge Function secrets / deployment env, not in the browser bundle. INDEXING_V3_AGENT_SECRET=your-long-random-cron-shared-secret +# Secret required for /api/health?deep=1 Supabase probe (x-health-deep-token header). +HEALTH_DEEP_PROBE_SECRET=your-long-random-health-deep-probe-secret # Local-only no-auth mode (development only). # Enable one or both of these to load real data without per-request browser auth: diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 48c530e9b..38ea80008 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -95,6 +95,15 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - Auth server is capped at 10 absolute DB connections (Supabase advisor); switch to percentage-based allocation in the dashboard before scaling instance size (not settable via SQL/MCP). - `storage_cleanup_jobs` live indexes drifted from `supabase/schema.sql`: live carries legacy auto-names (`storage_cleanup_jobs_document_id_idx`, `storage_cleanup_jobs_owner_id_idx`, a non-partial `storage_cleanup_jobs_status_created_idx`) that the hardening defs superseded. Migration `20260703030000_reconcile_storage_cleanup_jobs_indexes` is **prepared but NOT applied** — it drops the legacy names and (re)creates the intended named/partial indexes to match schema.sql. Functional-not-broken (the document_id FK is covered), so apply to live only with explicit approval. `20260703000000`/`010000` are also absent from live `schema_migrations` and will self-heal on the next `supabase db push`. +## Live database drift reconciliation (2026-07-05) + +- **RESOLVED:** `indexing_v3_agent_jobs` table + `claim_indexing_v3_agent_jobs` + `update_indexing_v3_agent_job_status` were recorded as applied but absent on live. Migration `20260705220000_reconcile_live_database_drift` idempotently re-applied them; live verified post-push. +- **RESOLVED:** `match_document_embedding_fields_text` codified with service_role-only execute. +- **RESOLVED:** `rag_visual_eval_*` tables codified with service_role-only RLS. +- **RESOLVED:** Live-only `20260705133000_tighten_search_document_chunks_owner_scope` mirrored in `schema.sql`. +- **Edge function follow-up:** deploy `indexing-v3-agent` after merge so JSONB status RPC parsing is live. +- **Operator-only:** publishable key rotation (`docs/archive/operator-decisions-2026-07-04.md`). + ## PR merge gate: tiered CI + required checks (2026-07-02) - CI is now two parallel PR jobs instead of one serial 6-7 minute job: `verify` (runtime alignment, edge typecheck, CI-safe production readiness, lint, typecheck, unit tests with coverage gate, build — ~3 min) and `ui-smoke` (Chromium Playwright smoke against its own dev server — ~4.5 min). Wall-clock PR feedback drops to the slower of the two, and a flaky smoke rerun no longer repeats lint/typecheck/tests/build. diff --git a/docs/source-review-priority-2026-07-02.md b/docs/source-review-priority-2026-07-02.md index 4c17926e3..29f73d510 100644 --- a/docs/source-review-priority-2026-07-02.md +++ b/docs/source-review-priority-2026-07-02.md @@ -8,7 +8,7 @@ ceiling 0.2) to 0.5398 in the afternoon run. The jump is not corpus decay: the r review-flagged sources are no longer buried and the metric now reports the true corpus state surfacing in golden-case top results. The bounded debt acceptance in `docs/release-source-metadata-debt-2026-06-30.json` was re-accepted at a 0.6 ceiling (expiry unchanged, -2026-07-31) on the condition that the documents below are clinically reviewed first. +2026-08-31) on the condition that the documents below are clinically reviewed first. Corpus context (live DB, 2026-07-02): 2,065 indexed documents — 1,397 current/locally_reviewed, 481 `review_due`, 132 `unknown` status, 130 `unverified` validation, 0 outdated, 0 poor-extraction. diff --git a/docs/supabase-migration-reconciliation.md b/docs/supabase-migration-reconciliation.md index 37ba5dbd3..f900e103d 100644 --- a/docs/supabase-migration-reconciliation.md +++ b/docs/supabase-migration-reconciliation.md @@ -1,6 +1,6 @@ # Supabase Migration Reconciliation -Last reviewed: 2026-07-04 +Last reviewed: 2026-07-05 Target project: Clinical KB Database (`sjrfecxgysukkwxsowpy`) @@ -27,7 +27,15 @@ These previously local-only versions were verified in the live project history b ## Current Status (July 2026) -The repo now includes additional July 2026 migrations beyond the June checkpoint above, including: +Migration `20260705220000_reconcile_live_database_drift.sql` codifies live-only drift discovered 2026-07-05: + +- `indexing_v3_agent_jobs` table and claim/update RPCs (recorded as applied in history but absent on live at inspection time) +- `match_document_embedding_fields_text` RPC with service-role-only execute grants (was present on live with anon/auth execute) +- `rag_visual_eval_cases` / `rag_visual_eval_runs` tables with service-role-only RLS (were present on live without RLS) + +`supabase/schema.sql` has been reconciled to match. Apply the migration through the normal linked workflow when ready; do not use raw dashboard SQL for retrieval RPCs. + +The repo also includes additional July 2026 migrations beyond the June checkpoint above, including: - Retrieval RPC codification and hybrid execution smoke (`20260701140631`, related July 1 fixes) - Legacy vector index drops and `search_schema_health()` reconciliation (`20260702014803`, `20260702021604`) diff --git a/package.json b/package.json index c38c76383..febd34b50 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "visual:backfill": "tsx scripts/backfill-visual-intelligence.ts", "backfill:text-normalization": "tsx scripts/backfill-text-normalization.ts", "check:supabase-project": "tsx scripts/check-supabase-project.ts", + "check:retrieval-owner": "tsx scripts/check-retrieval-owner-migration.ts", "check:indexing": "tsx scripts/check-indexing.ts", "check:type-scale": "node scripts/check-type-scale.mjs", "recover:ingestion": "tsx scripts/recover-ingestion-queue.ts", @@ -68,6 +69,7 @@ "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", "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", @@ -108,7 +110,6 @@ "pdfjs-dist": "^6.1.200", "pdfkit": "^0.19.1", "postcss": "^8.5.15", - "postgres": "3.4.9", "react": "19.2.7", "react-dom": "19.2.7", "zod": "^4.4.3" diff --git a/scripts/apply-governance-dashboard.mjs b/scripts/apply-governance-dashboard.mjs new file mode 100644 index 000000000..0957e5dc5 --- /dev/null +++ b/scripts/apply-governance-dashboard.mjs @@ -0,0 +1,135 @@ +import fs from "node:fs"; + +const path = "src/components/ClinicalDashboard.tsx"; +let s = fs.readFileSync(path, "utf8"); + +if (!s.includes("SourceReviewQueuePanel")) { + s = s.replace( + 'import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";', + 'import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results";\nimport { SourceReviewQueuePanel } from "@/components/clinical-dashboard/source-review-queue-panel";', + ); +} + +if (!s.includes("search-request-token")) { + s = s.replace( + `import { + frontendSourceGovernanceWarnings, + groupSourceGovernanceWarnings, + type SourceGovernanceWarning, +} from "@/lib/source-governance";`, + `import { + frontendSourceGovernanceWarnings, + groupSourceGovernanceWarnings, + serializeSourceGovernanceWarning, + type SourceGovernanceWarning, +} from "@/lib/source-governance"; +import { + invalidateSearchRequests, + isLatestSearchRequest, + type SearchRequestToken, +} from "@/lib/search-request-token";`, + ); +} + +s = s.replace( + "const searchRequestSeqRef = useRef(0);", + `const searchRequestSeqRef = useRef(0); + + function invalidateInFlightSearchRequests() { + searchRequestSeqRef.current = invalidateSearchRequests(searchRequestSeqRef.current); + }`, +); + +s = s.replace( + "const requestId = ++searchRequestSeqRef.current;", + `const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId;`, +); + +s = s.replace( + /if \(requestId === searchRequestSeqRef\.current\) setAnswerProgress\(message\);/g, + "if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) setAnswerProgress(message);", +); + +s = s.replace( + /if \(requestId === searchRequestSeqRef\.current\) \{/g, + "if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {", +); + +s = s.replace( + "function crossModeSearch(mode: AppModeId, crossQuery: string) {\n modeChangeFromUiRef.current = true;", + "function crossModeSearch(mode: AppModeId, crossQuery: string) {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;", +); + +s = s.replace( + "function selectSearchMode(mode: AppModeId) {\n modeChangeFromUiRef.current = true;", + "function selectSearchMode(mode: AppModeId) {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;", +); + +s = s.replace( + "function startNewChat() {\n modeChangeFromUiRef.current = true;", + "function startNewChat() {\n invalidateInFlightSearchRequests();\n modeChangeFromUiRef.current = true;", +); + +s = s.replace( + "sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message),", + "sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning),", +); + +s = s.replace( + `")) { + s = s.replace( + ` /> + + + `, + ` /> + + + + `, + ); +} + +const shortcutIdx = s.indexOf("async function runDocumentSearchShortcut"); +if (shortcutIdx !== -1) { + const canRunIdx = s.indexOf(" if (!canRunSearch) {", shortcutIdx); + const fnEnd = s.indexOf("\n function handleTagSearch", canRunIdx); + const block = s.slice(canRunIdx, fnEnd); + if (!block.includes("invalidateInFlightSearchRequests()")) { + const newBlock = block + .replace( + " setQuery(trimmedSearchText);", + " invalidateInFlightSearchRequests();\n setQuery(trimmedSearchText);", + ) + .replace( + " if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);\n\n try {", + " if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode);\n\n const requestId = invalidateSearchRequests(searchRequestSeqRef.current);\n searchRequestSeqRef.current = requestId;\n\n try {", + ) + .replace( + " applySearchResult(payload);", + " if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n applySearchResult(payload);\n }", + ) + .replace( + ' setError(requestError instanceof Error ? requestError.message : "Document search failed");', + ' if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n setError(requestError instanceof Error ? requestError.message : "Document search failed");\n }', + ) + .replace( + " setLoading(false);\n setAnswerProgress(null);", + " if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) {\n setLoading(false);\n setAnswerProgress(null);\n }", + ); + s = s.slice(0, canRunIdx) + newBlock + s.slice(fnEnd); + } +} + +fs.writeFileSync(path, s); +console.log("ClinicalDashboard governance edits applied"); diff --git a/scripts/capture-support-chip-screenshots.ts b/scripts/capture-support-chip-screenshots.ts new file mode 100644 index 000000000..2b8247c9b --- /dev/null +++ b/scripts/capture-support-chip-screenshots.ts @@ -0,0 +1,137 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { chromium } from "playwright-core"; + +import { getPlaywrightBaseUrl } from "./playwright-base-url"; +import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; + +const outputDir = process.env.SCREENSHOT_DIR ?? join(process.cwd(), "artifacts", "screenshots"); +mkdirSync(outputDir, { recursive: true }); + +const question = "What clozapine monitoring items are shown in the table image?"; + +const readySetupChecks = [ + { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, + { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Test Supabase project ready." }, + { id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." }, + { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search schema ready." }, + { id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." }, + { id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required for UI smoke." }, +]; + +function answerStreamBody(payload: unknown) { + return [ + `event: progress\ndata: ${JSON.stringify({ stage: "retrieving", message: "Searching indexed documents." })}`, + `event: final\ndata: ${JSON.stringify(payload)}`, + "", + ].join("\n\n"); +} + +async function mockDemoApi(page: import("playwright-core").Page, baseUrl: string) { + await page.route(/\/api\/local-project-id$/, async (route) => { + await route.fulfill({ + json: { + appName: "Clinical KB", + projectId: "test-project", + identityPath: "/api/local-project-id", + localServer: { + currentUrl: baseUrl, + currentPort: Number(new URL(baseUrl).port || 4298), + projectPortStart: 4298, + projectPortEnd: 53210, + safeLocalOrigin: true, + requestOrigin: null, + requestReferer: null, + unsafeLocalCaller: null, + }, + }, + }); + }); + await page.route("**/api/setup-status**", async (route) => { + await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); + }); + await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + documents: demoDocuments, + demoMode: true, + pagination: { + limit: 150, + offset: 0, + total: demoDocuments.length, + nextOffset: demoDocuments.length, + hasMore: false, + }, + }, + }); + }); + await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { + const body = route.request().postDataJSON() as { + query?: string; + documentId?: string; + documentIds?: string[]; + }; + const payload = demoAnswer(body?.query ?? question, body?.documentId, body?.documentIds); + const pathname = new URL(route.request().url()).pathname; + if (pathname.endsWith("/stream")) { + await route.fulfill({ + body: answerStreamBody(payload), + contentType: "text/event-stream; charset=utf-8", + headers: { "Cache-Control": "no-cache, no-transform" }, + }); + return; + } + await route.fulfill({ json: payload }); + }); +} + +async function main() { + const baseUrl = getPlaywrightBaseUrl(); + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 390, height: 820 } }); + await mockDemoApi(page, baseUrl); + await page.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle").catch(() => undefined); + + const questionInput = page + .locator('[aria-label^="Search indexed guidelines by question or keyword"]:visible') + .first(); + await questionInput.fill(question); + await page.locator('[aria-label="Generate source-backed answer"]:visible').first().click(); + + await page.getByTestId("plain-answer-response").waitFor({ state: "visible", timeout: 30_000 }); + await page.getByTestId("answer-follow-up-suggestions").waitFor({ state: "visible", timeout: 30_000 }); + await page.getByTestId("answer-support-action-row").waitFor({ state: "attached", timeout: 30_000 }); + + const mainContent = page.locator("#main-content"); + + await mainContent.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await page.waitForTimeout(500); + const expandedPath = join(outputDir, "support-chips-expanded-at-bottom.png"); + await page.screenshot({ path: expandedPath, fullPage: false }); + + await mainContent.evaluate((element) => { + element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 180); + }); + await page + .waitForFunction(() => { + const row = document.querySelector('[data-testid="answer-support-action-row"]'); + return row?.getAttribute("data-collapsed") === "true"; + }, { timeout: 10_000 }) + .catch(() => undefined); + await page.waitForTimeout(500); + const collapsedPath = join(outputDir, "support-chips-collapsed-above-composer.png"); + await page.screenshot({ path: collapsedPath, fullPage: false }); + + const collapsed = await page.getByTestId("answer-support-action-row").getAttribute("data-collapsed"); + console.log(JSON.stringify({ outputDir, collapsed, expandedPath, collapsedPath, baseUrl }, null, 2)); + + await browser.close(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/check-document-label-governance.ts b/scripts/check-document-label-governance.ts index c418edf4c..a6615f06f 100644 --- a/scripts/check-document-label-governance.ts +++ b/scripts/check-document-label-governance.ts @@ -175,7 +175,7 @@ async function main() { console.log(`Low confidence generated labels: ${report.analytics.lowConfidence}`); console.log(`Quality warnings: ${report.analytics.qualityIssues.length}`); console.log(`Blocking quality issues: ${report.analytics.blockingQualityIssues.length}`); - console.log(`Missing gold-label rows: ${report.analytics.missingGoldLabels.length}`); + console.log(`Missing gold-label rows (advisory): ${report.analytics.missingGoldLabels.length}`); console.log( `Relevance checks: ${report.relevanceChecks.filter((check) => check.passed).length}/${report.relevanceChecks.length} passed`, ); diff --git a/scripts/check-retrieval-owner-migration.ts b/scripts/check-retrieval-owner-migration.ts new file mode 100644 index 000000000..927d38148 --- /dev/null +++ b/scripts/check-retrieval-owner-migration.ts @@ -0,0 +1,75 @@ +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.cwd()); + +async function tryRpc( + supabase: Awaited>, + name: string, + args: Record, +) { + const { data, error } = await supabase.rpc(name as never, args as never); + return { + data, + error: error?.message ?? null, + code: (error as { code?: string } | null)?.code ?? null, + }; +} + +async function main() { + const { createAdminClient } = await import("../src/lib/supabase/admin"); + const supabase = createAdminClient(); + + console.log("Checking live Supabase: Clinical KB Database (sjrfecxgysukkwxsowpy)\n"); + + const health = await tryRpc(supabase, "search_schema_health", {}); + console.log("search_schema_health:", JSON.stringify(health, null, 2)); + + const helper = await tryRpc(supabase, "retrieval_owner_matches", { + owner_filter: "00000000-0000-0000-0000-000000000000", + row_owner_id: null, + }); + console.log("\nretrieval_owner_matches (sentinel + null owner):", JSON.stringify(helper, null, 2)); + + const { count: publicDocs, error: publicErr } = await supabase + .from("documents") + .select("id", { count: "exact", head: true }) + .is("owner_id", null) + .eq("status", "indexed"); + console.log("\nindexed public documents:", JSON.stringify({ count: publicDocs, error: publicErr?.message ?? null })); + + const sentinelSearch = await tryRpc(supabase, "match_document_chunks_text", { + query_text: "monitoring", + match_count: 5, + document_filters: null, + owner_filter: "00000000-0000-0000-0000-000000000000", + }); + console.log( + "\nmatch_document_chunks_text (public sentinel):", + JSON.stringify( + { + resultCount: Array.isArray(sentinelSearch.data) ? sentinelSearch.data.length : null, + error: sentinelSearch.error, + code: sentinelSearch.code, + }, + null, + 2, + ), + ); + + console.log("\n=== VERDICT ==="); + if (helper.code === "PGRST202" || helper.error?.includes("Could not find the function")) { + console.log("NOT APPLIED — retrieval_owner_matches is missing on live Supabase."); + process.exit(1); + } + if (helper.data === true && Array.isArray(sentinelSearch.data)) { + console.log("APPLIED — helper exists and hybrid RPC accepts the public sentinel."); + process.exit(0); + } + console.log("UNCLEAR — review the output above."); + process.exit(1); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/promote-public-documents.ts b/scripts/promote-public-documents.ts new file mode 100644 index 000000000..2621eec57 --- /dev/null +++ b/scripts/promote-public-documents.ts @@ -0,0 +1,191 @@ +import { loadEnvConfig } from "@next/env"; + +loadEnvConfig(process.cwd()); + +type PromoteArgs = { + ownerId?: string; + apply: boolean; + limit?: number; +}; + +const PROMOTABLE_VALIDATION_STATUSES = ["locally_reviewed", "approved"] as const; + +const RELATED_TABLES = [ + "document_labels", + "document_summaries", + "document_sections", + "document_memory_cards", + "document_table_facts", + "document_embedding_fields", + "document_index_quality", + "document_index_units", +] as const; + +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, + apply: false, + }; + + 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; + } + if (token === "--apply") { + args.apply = true; + continue; + } + if (token === "--limit") { + args.limit = Number(argv[index + 1]); + index += 1; + continue; + } + throw new Error(`Unknown argument: ${token}`); + } + + return args; +} + +type CandidateDocument = { + id: string; + title: string | null; + file_name: string | null; + owner_id: string | null; + metadata: Record | null; +}; + +async function fetchCandidates( + supabase: Awaited>, + ownerId?: string, + limit?: number, +) { + const candidates: CandidateDocument[] = []; + const pageSize = 200; + + for (let offset = 0; ; offset += pageSize) { + let query = supabase + .from("documents") + .select("id,title,file_name,owner_id,metadata") + .eq("status", "indexed") + .not("owner_id", "is", null) + .in("metadata->>clinical_validation_status", [...PROMOTABLE_VALIDATION_STATUSES]) + .order("updated_at", { ascending: false }) + .range(offset, offset + pageSize - 1); + + if (ownerId) query = query.eq("owner_id", ownerId); + + const { data, error } = await query; + if (error) throw new Error(error.message); + const rows = (data ?? []) as CandidateDocument[]; + candidates.push(...rows); + if (rows.length < pageSize) break; + if (limit && candidates.length >= limit) break; + } + + return limit ? candidates.slice(0, limit) : candidates; +} + +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 clearOwnerOnRelatedRows( + supabase: Awaited>, + table: (typeof RELATED_TABLES)[number], + documentIds: string[], +) { + const { error } = await supabase.from(table).update({ owner_id: null }).in("document_id", documentIds); + if (error) throw new Error(`${table}: ${error.message}`); +} + +async function promoteBatch( + supabase: Awaited>, + batch: CandidateDocument[], +) { + const now = new Date().toISOString(); + for (const document of batch) { + const metadata = { + ...(document.metadata ?? {}), + public_corpus: true, + }; + const { error } = await supabase + .from("documents") + .update({ owner_id: null, metadata, updated_at: now }) + .eq("id", document.id); + if (error) throw new Error(`documents/${document.id}: ${error.message}`); + } + + const documentIds = batch.map((document) => document.id); + for (const table of RELATED_TABLES) { + await clearOwnerOnRelatedRows(supabase, table, documentIds); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const supabase = await loadAdminClient(); + + const [candidates, publicBefore] = await Promise.all([ + fetchCandidates(supabase, args.ownerId, args.limit), + countPublic(supabase), + ]); + + console.log("[public-documents:promote] indexed public documents (before):", publicBefore); + console.log( + `[public-documents:promote] promotion candidates${args.ownerId ? ` for owner ${args.ownerId}` : ""}:`, + candidates.length, + ); + + if (candidates.length > 0) { + console.log("[public-documents:promote] sample candidates:"); + for (const document of candidates.slice(0, 8)) { + console.log( + ` - ${document.title ?? document.file_name ?? document.id} (${document.metadata?.clinical_validation_status ?? "unknown"})`, + ); + } + if (candidates.length > 8) console.log(` ... and ${candidates.length - 8} more`); + } + + if (!args.apply) { + console.log("\nDry run only. Re-run with --apply to promote these documents to the public corpus."); + return; + } + + if (candidates.length === 0) { + console.log("\nNothing to promote."); + return; + } + + const batchSize = 25; + let promoted = 0; + for (let offset = 0; offset < candidates.length; offset += batchSize) { + const batch = candidates.slice(offset, offset + batchSize); + await promoteBatch(supabase, batch); + promoted += batch.length; + console.log(`[public-documents:promote] promoted ${promoted}/${candidates.length}`); + } + + const publicAfter = await countPublic(supabase); + console.log("\n[public-documents:promote] indexed public documents (after):", publicAfter); + console.log(`[public-documents:promote] promoted ${promoted} document(s) to owner_id IS NULL.`); +} + +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..0084e60cd 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -4,7 +4,7 @@ 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 +70,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..ef4fb0e54 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -2,7 +2,7 @@ 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 +232,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/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index c94551a28..6e0df0589 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -121,10 +121,10 @@ export async function POST(request: Request) { try { if (isDemoMode()) return NextResponse.json({ error: "Bulk edits are unavailable in demo mode." }, { status: 400 }); - const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid."); - const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); + + const parsed = await parseJsonBody(request, bulkMetadataSchema, "Bulk edit payload is invalid."); const ids = Array.from(new Set(parsed.documentIds)); const { data: documents, error: documentsError } = await supabase diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index f70d0264b..521ef2737 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,24 +1,34 @@ +import { timingSafeEqual } from "node:crypto"; import { NextResponse } from "next/server"; import { env, isDemoMode } from "@/lib/env"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -// Liveness/readiness probe for load balancers and uptime monitors. It reports -// whether the server is configured to operate for real (Supabase + OpenAI present) -// and, with ?deep=1, whether Supabase is actually reachable. The payload exposes only -// boolean configuration presence and operational status — never secret values. +function allowDeepHealthProbe(request: Request): boolean { + const secret = env.HEALTH_DEEP_PROBE_SECRET; + if (!secret) return false; + const token = request.headers.get("x-health-deep-token"); + if (!token) return false; + const expected = Buffer.from(secret); + const received = Buffer.from(token); + if (expected.length !== received.length) return false; + return timingSafeEqual(expected, received); +} + export async function GET(request: Request) { const deep = new URL(request.url).searchParams.get("deep") === "1"; const supabaseConfigured = Boolean(env.NEXT_PUBLIC_SUPABASE_URL && env.SUPABASE_SERVICE_ROLE_KEY); - const checks: Record = { + const checks: Record = { supabaseConfig: supabaseConfigured ? "ok" : "missing", openaiConfig: env.OPENAI_API_KEY ? "ok" : "missing", }; if (deep) { - if (supabaseConfigured && !isDemoMode()) { + if (!allowDeepHealthProbe(request)) { + checks.supabase = "unauthorized"; + } else if (supabaseConfigured && !isDemoMode()) { try { const [{ createAdminClient }, { probeSupabaseHealth }] = await Promise.all([ import("@/lib/supabase/admin"), @@ -34,7 +44,8 @@ export async function GET(request: Request) { } } - const ready = !Object.values(checks).includes("missing") && !Object.values(checks).includes("error"); + const ready = + !Object.values(checks).some((value) => value === "missing" || value === "error" || value === "unauthorized"); return NextResponse.json( { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index a0474e14d..ea99d7aa3 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -14,7 +14,7 @@ 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 +893,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..081568601 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"; @@ -52,6 +51,10 @@ function check(id: SetupCheckId, label: string, status: SetupCheckStatus, detail return { id, label, status, detail }; } +function projectSetupCheckStatus(status: ReturnType["status"]) { + return status === "ready" || status === "warning" ? "ready" : "needs_setup"; +} + async function readSupabaseAvailability(supabase: AdminClient | null) { if (!requiredSupabaseEnvPresent || !supabaseProjectCanBeQueried || !supabase) return null; const now = Date.now(); @@ -297,7 +300,7 @@ async function buildSetupStatusPayload(): Promise { check( "project", "Clinical KB Database target", - supabaseProjectCheck.status === "ready" ? "ready" : "needs_setup", + projectSetupCheckStatus(supabaseProjectCheck.status), formatSupabaseProjectCheck(supabaseProjectCheck), ), check( @@ -354,7 +357,7 @@ async function buildSetupStatusPayload(): Promise { check( "project", "Clinical KB Database target", - supabaseProjectCheck.status === "ready" ? "ready" : "needs_setup", + projectSetupCheckStatus(supabaseProjectCheck.status), formatSupabaseProjectCheck(supabaseProjectCheck), ), schema, @@ -409,27 +412,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/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2545d2b24..d05570158 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1,18 +1,15 @@ "use client"; -import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import dynamic from "next/dynamic"; import { AlertCircle, Bell, BookOpen, - CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, - Copy, ExternalLink, FileImage, FileText, @@ -21,7 +18,6 @@ import { HelpCircle, Heart, Keyboard, - Layers, ListChecks, Loader2, LogOut, @@ -29,7 +25,6 @@ import { LockKeyhole, Palette, PanelTop, - Plus, Quote, RefreshCw, Search, @@ -48,66 +43,38 @@ import { import { type CSSProperties, type FormEvent, - type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import { AccessibleTable } from "@/components/AccessibleTable"; -import { - DocumentOrganizationBadges, - documentDisplayTitle, - documentOrganizationProfile, -} from "@/components/DocumentOrganizationBadges"; -import { DocumentTagCloud } from "@/components/DocumentTagCloud"; -import { DocumentManagementActions, type DocumentDeleteResult } from "@/components/DocumentManagementActions"; +import { type DocumentDeleteResult } from "@/components/DocumentManagementActions"; import { useDismissableLayer } from "@/components/use-dismissable-layer"; -import { formatCompactCitationLabel } from "@/lib/citations"; import { extractSafetyFindings } from "@/lib/clinical-safety"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; -import { isLocalNoAuthMode } from "@/lib/env"; +import { 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, @@ -129,42 +96,22 @@ import { import { GuideDialog, GuideTrigger, - SectionHeading, UtilityDrawer, } from "@/components/clinical-dashboard/dashboard-shell"; import { - cleanDisplayTitle, sanitizeAnswerDisplayText, sanitizeDisplayText, } from "@/components/clinical-dashboard/display-text"; import { NaturalLanguageAnswer, ScopeAndGovernanceNotice, - SourceImage, UserQuestionBubble, } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; -import { - AnswerFeedbackPanel, - AnswerSafetyNotice, - AnswerSupportSummaryCard, - answerHasCentralTable, - answerSupportPriority, - ClinicalNotesChecklistPanel, - clinicalNotesCount, - clinicalNotesDisplayCountForAnswer, - compactEvidenceSummary, - type EvidenceTabName, - simpleClinicalTableProps, - evidenceMapRowsFromRenderModel, - evidenceTabCount, - evidenceTabOrder, - QuoteCards, - SafetyFindingsListContent, -} from "@/components/clinical-dashboard/evidence-panels"; +import { evidenceMapRowsFromRenderModel } from "@/components/clinical-dashboard/evidence-panels"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { SearchCommandProvider } from "@/components/clinical-dashboard/search-command-context"; -import { emptyStates, errorCopy } from "@/lib/ui-copy"; +import { errorCopy } from "@/lib/ui-copy"; import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; import { DrawerGroupLabel, @@ -198,7 +145,8 @@ const DocumentDrawer = dynamic( ); import { DocumentSearchResultsPanel, type SearchFacets } from "@/components/clinical-dashboard/document-search-results"; -import { isWeakRelevance, QueryCoverageChips } from "@/components/clinical-dashboard/relevance"; +import { SourceReviewQueuePanel } from "@/components/clinical-dashboard/source-review-queue-panel"; +import { isWeakRelevance } from "@/components/clinical-dashboard/relevance"; import { answerPayloadIsUsable, isRetryableError, @@ -236,20 +184,21 @@ 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, + serializeSourceGovernanceWarning, type SourceGovernanceWarning, } from "@/lib/source-governance"; -import { smartEvidenceTags } from "@/lib/evidence-tags"; import { - tagSearchText, + invalidateSearchRequests, + isLatestSearchRequest, + type SearchRequestToken, +} from "@/lib/search-request-token"; +import { type SmartDocumentTag, type SmartDocumentTagFacet, - type SmartDocumentTagTier, - type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { ClinicalDocument, @@ -263,16 +212,13 @@ import type { RelatedDocument, SearchResult, SearchScopeSummary, - VisualEvidenceCard, ClinicalQueryMode, DocumentLabel, - DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { differentialsMobileCompareAddonSlotId, modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; import { createQuoteFollowUp, - type AnswerEvidenceMapRow, type AnswerViewMode, shouldPollForUpdates, } from "@/lib/ward-output"; @@ -487,367 +433,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 +1759,11 @@ 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 +1944,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 +2516,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, ); @@ -3061,7 +2657,11 @@ export function ClinicalDashboard({ // resolve out of order; only the latest request may commit answer/sources/ // error/loading state, or a stale response would display one query's answer // under another query's composer text. - const searchRequestSeqRef = useRef(0); + const searchRequestSeqRef = useRef(0); + + function invalidateInFlightSearchRequests() { + searchRequestSeqRef.current = invalidateSearchRequests(searchRequestSeqRef.current); + } function applySearchResult(payload: SearchResultModePayload, displayQuery?: string) { if (payload.kind === "documents") { @@ -3125,7 +2725,8 @@ export function ClinicalDashboard({ // library, whose single `document_labels.in()` request produces an // over-long PostgREST URL that fails on large corpora. Corpus search runs // unscoped (like Documents); users opt into label filters explicitly. - const requestId = ++searchRequestSeqRef.current; + const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId; setSearchMode(targetMode); // Answer mode keeps the composer as the draft source until a successful @@ -3179,7 +2780,7 @@ export function ClinicalDashboard({ // must also be discarded once a newer search takes over, or a slow stale // request repaints the progress banner under the newer query. const onProgress = (message: string | null) => { - if (requestId === searchRequestSeqRef.current) setAnswerProgress(message); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) setAnswerProgress(message); }; setLoading(true); setError(null); @@ -3253,7 +2854,7 @@ export function ClinicalDashboard({ } // M10: discard a stale response — a newer search owns the UI state. - if (requestId === searchRequestSeqRef.current) { + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { applySearchResult(successfulPayload, trimmedQuery); if (successfulPayload.kind === "answer") { // The composer is a draft box in a conversation: clear it so the @@ -3272,11 +2873,11 @@ export function ClinicalDashboard({ } } } catch (requestError) { - if (requestId === searchRequestSeqRef.current) { + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { setError(requestError instanceof Error ? requestError.message : "Search failed"); } } finally { - if (requestId === searchRequestSeqRef.current) { + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { setLoading(false); setAnswerProgress(null); } @@ -3344,6 +2945,7 @@ export function ClinicalDashboard({ } function crossModeSearch(mode: AppModeId, crossQuery: string) { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setCommandScopes([]); @@ -3403,7 +3005,7 @@ export function ClinicalDashboard({ sourceChunkIds, citedChunkIds, sourceFiles, - sourceGovernanceWarnings: sourceGovernanceWarnings.map((warning) => warning.message), + sourceGovernanceWarnings: sourceGovernanceWarnings.map(serializeSourceGovernanceWarning), unverifiedNumericTokens: answer.unverifiedNumericTokens ?? [], }), }); @@ -3479,6 +3081,7 @@ export function ClinicalDashboard({ return; } + invalidateInFlightSearchRequests(); setQuery(trimmedSearchText); setSearchMode(targetMode); setModeSearchSubmitted(true); @@ -3496,17 +3099,26 @@ export function ClinicalDashboard({ window.requestAnimationFrame(() => mainRef.current?.scrollTo({ top: 0, behavior: "smooth" })); if (updateUrl) updateDocumentSearchUrl(trimmedSearchText, targetMode); + const requestId = invalidateSearchRequests(searchRequestSeqRef.current); + searchRequestSeqRef.current = requestId; + try { const shortcutQueryMode = appModeQueryMode(targetMode, queryMode); const payload = await runWithRetries(() => requestSourceLibrarySearch(trimmedSearchText, sourceLibraryMode, filtersOverride, shortcutQueryMode), ); - applySearchResult(payload); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + applySearchResult(payload); + } } catch (requestError) { - setError(requestError instanceof Error ? requestError.message : "Document search failed"); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + setError(requestError instanceof Error ? requestError.message : "Document search failed"); + } } finally { - setLoading(false); - setAnswerProgress(null); + if (isLatestSearchRequest(requestId, searchRequestSeqRef.current)) { + setLoading(false); + setAnswerProgress(null); + } } } @@ -3595,6 +3207,7 @@ export function ClinicalDashboard({ } function selectSearchMode(mode: AppModeId) { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; if (mode === "differentials") clearDifferentialModeResultState(); setQuery(""); @@ -3638,6 +3251,7 @@ export function ClinicalDashboard({ } function startNewChat() { + invalidateInFlightSearchRequests(); modeChangeFromUiRef.current = true; const href = appModeHomeHref("answer", { focus: true }); setQuery(""); @@ -4005,21 +3619,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 +3658,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 +3697,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 +4277,7 @@ export function ClinicalDashboard({ @@ -4704,6 +4322,7 @@ export function ClinicalDashboard({ onReindex={reindexDocument} onEnrich={enrichDocument} /> + diff --git a/src/components/clinical-dashboard/answer-content.tsx b/src/components/clinical-dashboard/answer-content.tsx index ff148551d..eefa782d3 100644 --- a/src/components/clinical-dashboard/answer-content.tsx +++ b/src/components/clinical-dashboard/answer-content.tsx @@ -23,6 +23,7 @@ import { chatMicroAction, cn, sourceCapsule, + statusDotDanger, statusDotMuted, statusDotReady, statusDotReview, @@ -35,9 +36,16 @@ import { comparableAnswerText, sanitizeAnswerDisplayText, } from "@/components/clinical-dashboard/display-text"; +import { SourcePreviewPopover } from "@/components/clinical-dashboard/source-preview-popover"; import { useMobilePreviewSheet } from "@/components/clinical-dashboard/use-mobile-preview-sheet"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; -import { normalizeSourceMetadata, sourceStatusLabel } from "@/lib/source-metadata"; +import { + extractionQualityLabel, + normalizeSourceMetadata, + sourceStatusLabel, + sourceStatusNeedsAttention, + validationStatusShortLabel, +} from "@/lib/source-metadata"; import { clinicalProseUsefulness } from "@/lib/source-text-sanitizer"; import { frontendSourceGovernanceWarnings, @@ -260,8 +268,15 @@ function sourceCapsuleText({ export function sourceStatusDotClass(metadata: ReturnType | null | undefined) { if (!metadata) return statusDotMuted; + if (metadata.document_status === "outdated" || metadata.extraction_quality === "poor") return statusDotDanger; + if ( + metadata.document_status === "review_due" || + metadata.clinical_validation_status === "unverified" || + metadata.extraction_quality === "partial" + ) { + return statusDotReview; + } if (metadata.document_status === "current") return statusDotReady; - if (metadata.document_status === "review_due" || metadata.document_status === "outdated") return statusDotReview; return statusDotMuted; } @@ -282,7 +297,14 @@ function sourceBadgeLabel(index: number) { } function sourceBadgeToneClass(metadata: ReturnType, index: number) { - if (metadata.document_status === "review_due" || metadata.document_status === "outdated") { + if (metadata.document_status === "outdated" || metadata.extraction_quality === "poor") { + return "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]"; + } + if ( + metadata.document_status === "review_due" || + metadata.clinical_validation_status === "unverified" || + metadata.extraction_quality === "partial" + ) { return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; } if (index === 0) { @@ -302,6 +324,10 @@ function sourceSupportLabel(source: CapsulePreviewSource, index: number) { function sourceStatusShortLabel(metadata: ReturnType) { if (metadata.document_status === "review_due") return "Review due"; if (metadata.document_status === "outdated") return "Outdated"; + if (metadata.clinical_validation_status === "unverified") return validationStatusShortLabel(metadata); + if (metadata.extraction_quality === "partial" || metadata.extraction_quality === "poor") { + return extractionQualityLabel(metadata); + } if (metadata.document_status === "current") return "Current"; return sourceStatusLabel(metadata); } @@ -380,9 +406,10 @@ function SourcePreviewContent({ showHeader?: boolean; }) { const primaryPreviewSource = previewSources[0] ?? null; - const reviewDueSource = previewSources.find( - (source) => source.metadata.document_status === "review_due" || source.metadata.document_status === "outdated", - ); + const attentionSource = previewSources.find((source) => sourceStatusNeedsAttention(source.metadata)); + const attentionIsDanger = + attentionSource?.metadata.document_status === "outdated" || + attentionSource?.metadata.extraction_quality === "poor"; return ( <> @@ -447,8 +474,11 @@ function SourcePreviewContent({