diff --git a/.agents/plugins/api_marketplace.json b/.agents/plugins/api_marketplace.json new file mode 100644 index 0000000000..bd87e4200c --- /dev/null +++ b/.agents/plugins/api_marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "clinical-kb-api-local", + "interface": { + "displayName": "Clinical KB local" + }, + "plugins": [ + { + "name": "clinical-kb", + "source": { + "source": "local", + "path": "./plugins/clinical-kb" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000000..f164c3d065 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "clinical-kb-local", + "interface": { + "displayName": "Clinical KB local" + }, + "plugins": [ + { + "name": "clinical-kb", + "source": { + "source": "local", + "path": "./plugins/clinical-kb" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.gitignore b/.gitignore index d53f67081d..e01c0271ac 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,10 @@ /.playwright-cli/ /playwright/.auth/ /.codex/ -/.agents/ +/.agents/* +!/.agents/plugins/ +!/.agents/plugins/marketplace.json +!/.agents/plugins/api_marketplace.json /skills-lock.json # next.js diff --git a/docs/codex-prompt-playbook.md b/docs/codex-prompt-playbook.md new file mode 100644 index 0000000000..8cae8d4f13 --- /dev/null +++ b/docs/codex-prompt-playbook.md @@ -0,0 +1,659 @@ +# Codex Prompt Playbook + +This playbook contains copy/paste prompts for common Clinical KB work. The +prompts are written for this repository, not for a generic Next.js project. + +Before using any prompt, keep these project defaults in mind: + +- Start from `AGENTS.md`, `README.md`, `package.json`, and current `git status`. +- Preserve unrelated staged, unstaged, and untracked work. +- Use Node 24.x and npm 11.x. Do not switch package managers. +- For Next.js source changes, read the relevant guide under + `node_modules/next/dist/docs/` before editing. +- Use `npm run ensure` before browser/UI work and use the URL it prints. +- Do not assume ports `3000`, `3001`, or `3002`. +- Do not attach to a local server unless `/api/local-project-id` confirms this + project. +- Treat the live Supabase project as `Clinical KB Database` + (`sjrfecxgysukkwxsowpy`). Do not use the stale `qjgitjyhxrwxsrydablr` ref. +- Ask before running live provider/API work, OpenAI calls, Supabase mutations, + production data operations, deploys, commits, pushes, or destructive cleanup. +- For source/config/test changes, prefer `npm run verify:cheap` as the first + broad gate after focused checks. +- For UI/routing/styling/browser changes, run `npm run ensure` before browser + QA and use `npm run verify:ui` as the Chromium gate. +- For clinical ingestion, answer generation, source governance, privacy, + production-readiness, or environment changes, run the smallest relevant + domain check plus `npm run check:production-readiness`. + +## 1. First Repo Orientation + +Use this when starting a fresh session or handing the repo to another agent. + +```text +Review this repository from the current checkout before making changes. + +Start read-only. Inspect AGENTS.md, README.md, package.json scripts, git branch, +git status, recent commits, docs/process-hardening.md, +.github/pull_request_template.md, and the main source layout under src, scripts, +tests, worker, and supabase. + +Summarize: +- what this app does +- runtime/package manager requirements +- local server workflow +- verification gates +- clinical governance constraints +- Supabase project safety rules +- high-risk areas of the codebase +- current dirty/untracked work that must be preserved + +Do not install, test, build, run APIs, commit, push, or edit files yet. +``` + +## 2. Safe Local Setup Check + +Use this when you want setup validated without changing dependencies or data. + +```text +Check whether this repo is ready for local development. + +Start read-only. Inspect Node/npm versions, package manager, lockfile, .nvmrc, +.node-version, .npmrc, package scripts, .env.example, README setup steps, and +existing local env files without printing secret values. + +Report: +- installed Node/npm versions versus required versions +- package manager and lockfile detected +- whether node_modules appears present and healthy enough to run scripts +- required local tools, including Deno and optional Python/OCR prerequisites +- missing or suspicious setup items +- exact next commands I should run, separating safe local checks from commands + that would contact OpenAI, Supabase, or production-like services + +Do not run install, dependency update, provider-backed checks, API calls, tests, +builds, commits, pushes, or cleanup unless I explicitly approve. +``` + +## 3. Run The App Safely + +Use this instead of asking for a guessed localhost URL. + +```text +Run the Clinical KB app safely. + +Follow AGENTS.md local-server safety. Execute npm run ensure, let it choose the +project-specific URL, verify the server identity through the repo helper, and +return the printed URL plus the log path if one is provided. + +Do not assume localhost:3000, 3001, or 3002. Do not kill or modify other local +servers. Do not start a permanent watcher beyond what npm run ensure manages. +``` + +## 4. Implement A Focused Bug Fix + +Use this for most backend, library, or component bugs. + +```text +Fix this bug in the smallest safe way: + +[describe the bug, observed behavior, expected behavior, and any route/file/test +names here] + +Before editing: +- inspect AGENTS.md and relevant docs +- check branch and git status +- preserve unrelated dirty/untracked work +- read the relevant source and tests +- if touching Next.js APIs, read the matching guide in node_modules/next/dist/docs/ + +During implementation: +- identify the root cause from code or tests, not a guess +- keep changes scoped to the affected module +- add or adjust focused tests only where they prove the fixed behavior +- avoid unrelated refactors, dependency changes, API calls, commits, pushes, and + cleanup + +Verification: +- run the smallest relevant focused test first +- then run npm run verify:cheap if the change is non-trivial +- if clinical output, source governance, ingestion, privacy, Supabase, or env + behavior changed, also run npm run check:production-readiness + +Final response: summarize files changed, root cause, fix, checks run, checks not +run, and any residual risk. +``` + +## 5. UI Or Frontend Change + +Use this for dashboard, document viewer, routes, styling, accessibility, or +responsive work. + +```text +Implement this UI/frontend change: + +[describe the user-facing change, target route, viewport requirements, and any +must-preserve behavior] + +Before editing: +- inspect AGENTS.md, README.md, package scripts, and current git status +- run npm run ensure before browser work and use the printed URL +- read the relevant Next.js docs under node_modules/next/dist/docs/ before + changing route/layout/app APIs +- inspect existing components, tokens, tests, and docs for this UI area + +Implementation constraints: +- preserve current mode-aware behavior, source/citation/document workflows, and + data-testid/aria contracts +- keep layout practical and dense enough for repeated clinical work +- avoid landing-page or marketing-style redesign unless specifically requested +- do not change RAG ranking, answer generation, ingestion, Supabase behavior, or + API shapes unless the UI change cannot work without it +- avoid adding dependencies unless there is a strong reason and I approve + +Verification: +- run focused tests for touched UI behavior where available +- run browser QA at desktop and mobile widths using the npm run ensure URL +- run npm run verify:ui for the Chromium UI gate +- run npm run verify:cheap before handoff if source behavior changed +- run npm run check:production-readiness if source rendering, clinical output, + privacy, or governance behavior changed + +Final response: include screenshots or paths if captured, files changed, checks +run, browser states verified, and known limitations. +``` + +## 6. Browser QA And Screenshot Review + +Use this when you want visual proof rather than code-only review. + +```text +Run browser QA for these routes/states: + +[list routes, states, and desktop/mobile viewport sizes] + +Follow local-server safety: +- run npm run ensure +- use only the printed URL +- verify /api/local-project-id before attaching +- do not kill unrelated servers + +Check: +- desktop and mobile layout +- no incoherent text overlap or horizontal overflow +- keyboard/focus behavior for interactive controls +- forced-colors and reduced-motion behavior when relevant +- source/document/image panels if touched + +Capture screenshots to a repo-ignored or temp location. Do not commit generated +screenshots unless I explicitly ask. Summarize findings with file paths for any +screenshots and exact routes/viewports tested. +``` + +## 7. RAG Answer Quality Fix + +Use this when answers, citations, source trust, or synthesis behavior regress. + +```text +Investigate and fix this RAG answer quality issue: + +[include query, observed answer, expected answer, source/citation problem, and +whether live provider calls are allowed] + +Default to no live API/provider calls unless I explicitly approve. Start from +local code, fixtures, tests, eval cases, cached/demo behavior, and logs. + +Inspect: +- src/lib/rag.ts +- src/lib/rag-answer-text.ts +- src/lib/rag-routing.ts +- src/lib/retrieval-selection.ts +- src/lib/answer-render-policy.ts +- src/lib/source-governance.ts +- relevant API routes and tests +- docs/search-rag-* context where applicable + +Fix constraints: +- preserve conservative behavior for unknown/outdated sources +- keep citations/source links verifiable +- do not weaken privacy, owner scoping, or source-governance rules +- bump cache/version keys only when behavior changes require it + +Verification: +- run the focused RAG/citation/source tests that cover the changed behavior +- run relevant evals only if they are local-safe or I approve provider usage +- run npm run verify:cheap for non-trivial changes +- run npm run check:production-readiness for answer generation, source + governance, privacy, or clinical output changes + +Final response: explain root cause, changed files, behavior before/after, checks, +and any cases still needing live evaluation. +``` + +## 8. Retrieval Or Search Diagnostics + +Use this when search misses documents, ranks poorly, or returns confusing source +sets. + +```text +Diagnose this retrieval/search issue: + +[include query, missing expected source, wrong source, document title/page/chunk +if known, and whether live Supabase/API checks are approved] + +Start with local/static analysis unless I approve live Supabase/API calls. + +Inspect: +- src/lib/clinical-search.ts +- src/lib/retrieval-selection.ts +- src/lib/search-scope.ts +- src/lib/rag-routing.ts +- src/lib/document-index-units.ts +- src/lib/indexed-source-formatting.ts +- scripts/eval-search.ts +- scripts/eval-retrieval.ts +- relevant tests and fixtures + +Look for: +- query normalization or clinical vocabulary issues +- source scope/filter mismatch +- hybrid/vector/text ranking imbalance +- cache invalidation/version drift +- document label or generated metadata gaps +- owner scoping or private-access behavior + +Verification: +- run focused tests first +- run local-safe eval/search checks where possible +- if ingestion, generated labels, or live indexed data are involved, ask before + live Supabase calls and include npm run check:document-label-coverage if run +- run npm run check:production-readiness when clinical source behavior changes +``` + +## 9. Upload, Ingestion, OCR, Or Worker Fix + +Use this for document upload, queue, parsing, OCR, image extraction, or indexing +work. + +```text +Fix this upload/ingestion/worker issue: + +[describe failing upload, job state, worker log snippet, document type, and +whether live Supabase/OpenAI work is approved] + +Start from local evidence. Do not run live Supabase mutations, OpenAI calls, +worker jobs, or data cleanup unless I approve. + +Inspect: +- src/app/api/upload +- src/app/api/ingestion +- src/app/api/jobs +- src/lib/ingestion*.ts +- src/lib/extractors +- worker/main.ts and worker/index.ts +- worker/python prerequisites when OCR/PDF extraction is involved +- supabase migrations/RPCs tied to job state +- tests for ingestion, worker, indexing, private access, and file signatures + +Fix constraints: +- preserve owner scoping and private bucket access +- keep service-role usage server-only +- avoid patient-identifiable workflow expansion +- avoid broad cleanup of storage/database data unless explicitly approved + +Verification: +- run focused unit/route tests +- run npm run check:indexing only if local OCR prerequisites are expected to be + present +- run npm run check:production-readiness for ingestion, privacy, source + governance, or environment changes +- run npm run check:supabase-project after Supabase env/config changes +``` + +## 10. Supabase Schema Or Migration Change + +Use this before touching SQL, RLS, RPCs, policies, buckets, or project env. + +```text +Plan and implement this Supabase/schema change safely: + +[describe the desired schema/RLS/RPC/policy/env change and whether live Supabase +commands are approved] + +Start read-only: +- inspect AGENTS.md Supabase project safety +- inspect supabase/schema.sql, relevant migrations, generated types/usages, + scripts/check-supabase-project.ts, and tests +- confirm expected project ref is sjrfecxgysukkwxsowpy +- do not use qjgitjyhxrwxsrydablr + +Before live commands or mutations, stop and ask for explicit approval. + +Implementation: +- create the smallest migration needed +- preserve RLS and owner scoping +- keep service-role policies limited to server/worker contexts +- update tests and app code that depend on changed DB shape +- document rollback or compatibility concerns for risky changes + +Verification: +- run focused schema/RPC tests +- run npm run check:supabase-project after env/config changes +- run npm run check:production-readiness for source governance, privacy, or + clinical workflow impact +- run npm run verify:cheap for non-trivial source/config/test changes +``` + +## 11. API Route Contract Hardening + +Use this for validation, auth, owner scoping, and error-shape improvements. + +```text +Harden these API routes: + +[list route families, e.g. documents/jobs/ingestion/upload/search/answer] + +Before editing: +- inspect relevant route files, src/lib/validation helpers, auth helpers, + Supabase client/admin usage, and route tests +- read relevant Next.js route-handler docs under node_modules/next/dist/docs/ +- preserve current response contracts unless a contract change is required + +Check for: +- missing schema validation +- unsafe route param/query/body parsing +- service-role use outside server-only boundaries +- owner scoping gaps +- inconsistent errors/status codes +- private document/image URL exposure +- rate-limit or audit logging regressions + +Verification: +- add or update focused route contract tests +- run the specific route tests first +- run npm run verify:cheap for broad local confidence +- run npm run check:production-readiness if privacy, source governance, or + clinical output behavior changed +``` + +## 12. Security And Privacy Review + +Use this for an actual security pass with concrete findings. + +```text +Review this repo for security and privacy issues, then fix high-confidence +findings that are safe and scoped. + +Focus on: +- secrets and env handling without printing secret values +- service-role key confinement +- private Supabase bucket access +- owner scoping across API routes and RAG/search paths +- RLS assumptions and policy coverage +- patient-identifiable data risks +- audit logging and query retention +- dependency vulnerabilities without forced audit fixes + +Start read-only. Preserve unrelated work. Do not contact live providers, mutate +Supabase, run OpenAI calls, deploy, commit, push, or run destructive cleanup +without explicit approval. + +For each finding, provide file/line evidence, impact, and a minimal fix. Fix only +high-confidence issues that are clearly in scope. Run focused tests, then +npm run verify:cheap where appropriate, and npm run check:production-readiness +for privacy/governance changes. +``` + +## 13. Repo Audit For Dead Code, Broken Imports, Duplication + +Use this for repo-auditor style cleanup. + +```text +Run a repo-auditor style pass and fix only high-confidence issues. + +Start read-only: +- inspect branch, git status, package scripts, tsconfig, next config, tests, and + source layout +- map src/app, src/components, src/lib, scripts, worker, supabase, and tests +- preserve unrelated dirty/untracked work + +Look for: +- broken imports +- files that are truly unused and not route entries, scripts, fixtures, mockups, + migrations, generated-type dependencies, or test assets +- duplicate helpers/config/styles that can be safely consolidated +- oversized modules with existing decomposition plans + +Use tools such as rg, TypeScript/lint output, knip, or jscpd only as triage. +Verify every candidate with source search and repo context before deleting or +moving anything. + +If fixes are small and safe, make them. If cleanup is risky or architectural, +stop with a concrete plan instead. + +Verification: run focused tests for touched areas and npm run verify:cheap for +non-trivial changes. +``` + +## 14. Dependency Maintenance + +Use the repository shortcut when you want the full workflow. + +```text +dependency +``` + +If you want to spell it out instead: + +```text +Perform safe dependency maintenance for this repo. + +Follow the dependency shortcut in AGENTS.md exactly. Start read-only, preserve +all user work, use npm and the existing package-lock.json, avoid prereleases, +avoid forced/legacy resolver flags, inspect release notes for major/core updates, +make only required compatibility changes, regenerate the existing lockfile, and +verify with the repo's relevant gates. + +Do not commit, push, deploy, switch package managers, discard work, or run forced +audit fixes without explicit confirmation. +``` + +## 15. Release Readiness Review + +Use this before claiming the branch is ready. + +```text +Review this branch for release readiness. + +Start read-only: +- inspect branch/upstream/status and recent commits +- preserve unrelated work +- inspect package scripts, CI workflows, PR template, docs/process-hardening.md, + docs/clinical-governance.md, and relevant changed files + +Check: +- tests/lint/type/build coverage appropriate to the diff +- UI/browser coverage for frontend changes +- production-readiness implications +- clinical governance preflight items +- Supabase target safety +- dependency/audit concerns +- generated artifacts or secret-like files accidentally present + +Run the smallest relevant verification first. Use npm run verify:release only +when release-confidence verification is requested or clearly appropriate. + +Final response: findings first, then checks run, checks not run, residual risk, +and exact next steps to reach release confidence. +``` + +## 16. Pull Request Prep + +Use this to get a branch ready for review without pushing unless requested. + +```text +Prepare this branch for a pull request, but do not commit or push unless I +explicitly ask. + +Inspect: +- current branch/upstream/status +- staged, unstaged, and untracked work +- changed files and diff +- recent branch commits +- PR template requirements +- relevant verification gates + +Produce: +- a concise PR summary +- test plan with exact commands actually run +- clinical governance preflight answers if applicable +- risks/follow-up items +- list of generated/untracked files that should not be included + +If the diff has unrelated or WIP changes, separate them into groups and ask what +belongs in the PR. +``` + +## 17. Safe Upload/Handoff + +Use the repository shortcut when completed work should be committed and pushed +where safe. + +```text +upload +``` + +If you want a more explicit version: + +```text +Safely hand off completed work on this branch. + +Follow the upload shortcut in AGENTS.md exactly. Start with read-only git and +repo inspection. Preserve unrelated work. Stage only coherent completed changes. +Do not commit suspicious files such as env files, secrets, logs, caches, build +outputs, generated screenshots, or temporary artifacts. + +Run the smallest relevant verification available. Commit and push only when the +repo state makes that clearly safe under AGENTS.md. Do not force-push, rebase a +shared branch, delete branches, merge to main, deploy, or discard work without +explicit confirmation. + +Final response must include branch/worktree state, commit hash/message if +created, pushed branch if pushed, checks run, skipped/risky actions, and any +confirmation needed. +``` + +## 18. Production Readiness Or Clinical Governance Change + +Use this when changing clinical behavior, source policy, privacy, deployment, or +environment assumptions. + +```text +Implement this production-readiness/clinical-governance change: + +[describe the policy, source, privacy, environment, deployment, or clinical +behavior change] + +Before editing: +- inspect docs/clinical-governance.md +- inspect docs/production-readiness-checklist.md +- inspect .github/pull_request_template.md +- inspect scripts/production-readiness.ts and relevant tests +- confirm Supabase target safety rules + +Do not run live OpenAI/Supabase/provider operations, mutate production-like +state, deploy, commit, or push unless I approve. + +Implementation: +- keep unknown/outdated source behavior conservative +- keep service-role and private document access server-only +- keep demo/synthetic content separated from real clinical sources +- update docs/tests when behavior or policy changes + +Verification: +- run focused tests +- run npm run check:production-readiness +- run npm run check:supabase-project if Supabase env/config changed +- run npm run verify:cheap for non-trivial source/config/test changes +``` + +## 19. Test Failure Or Flake Diagnosis + +Use this when a check fails or times out. + +```text +Diagnose this failing check efficiently: + +[paste command, failure output, timeout, and what changed recently] + +Start from concrete evidence: +- inspect current git status +- identify whether the failure is from install health, runtime version, stale + local server, actual assertion failure, or timeout +- inspect the exact failing test and source under test +- rerun only the smallest failing test first + +Do not keep rerunning broad gates until the failure mode is understood. If the +failure is environment/install/server state, prove that with a targeted command +before changing source. + +After fixing, rerun the smallest failing check, then widen to the appropriate +repo gate only if needed. +``` + +## 20. Codebase Appraisal Export + +Use this when you want a clean archive for external review. + +```text +Create a reviewable codebase export ZIP for this repo. + +Inspect the repo first. Include source, config, tests, docs, package manifests, +lockfiles, CI config, and an EXPORT_MANIFEST.md. Exclude .git, node_modules, +.next, caches, logs, test artifacts, generated screenshots, local state, real +.env files, secrets, credentials, and dependency/build outputs. + +Stage the archive outside source when possible. Verify the ZIP can be opened, +contains EXPORT_MANIFEST.md, and does not contain forbidden paths. Do not commit, +push, deploy, install, test, or modify source behavior. +``` + +## 21. Large Feature Planning Before Code + +Use this when the change could sprawl across UI, API, DB, worker, and tests. + +```text +Create an implementation plan for this feature before coding: + +[describe feature, users, constraints, target routes, data model impact, and +whether provider/API work is allowed] + +Inspect current repo state and relevant docs/source. Produce a plan that +includes: +- current architecture touched +- proposed file-level changes +- risky assumptions +- data/schema/API implications +- clinical governance implications +- test plan +- verification commands +- steps that require explicit approval, such as live API calls, Supabase + mutations, dependency changes, deploys, commits, or pushes + +Do not edit files yet. +``` + +## 22. Review A Proposed Diff + +Use this when you want strict code-review output. + +```text +Review the current diff as a senior engineer. + +Prioritize bugs, regressions, missing tests, privacy/security issues, clinical +governance risk, and verification gaps. Start with findings ordered by severity, +with file/line references. Keep summary secondary. + +Inspect AGENTS.md, current git status, changed files, and relevant tests/docs. +Do not modify files unless I explicitly ask for fixes after the review. +``` diff --git a/docs/retrieval-quality-runbook.md b/docs/retrieval-quality-runbook.md index a02dd0cf6c..972fac1740 100644 --- a/docs/retrieval-quality-runbook.md +++ b/docs/retrieval-quality-runbook.md @@ -85,15 +85,18 @@ Source governance: - unverified top-result count - unknown-extraction top-result count - poor-extraction top-result count -- stale/outdated top-result rate -- combined stale/review/unknown top-result audit rate -- review-required top-result count and rate +- primary top-result stale rate +- primary top-result review-required count and rate +- supporting top-5 review-required count and rate for corpus-review prioritization Metadata policy: - `unknown`, `unverified`, `review_due`, `outdated`, unknown extraction, and poor extraction are treated as review-required. +- `stale_rate` is reserved for truly outdated primary top results. Review-due and unknown-status primary top results remain review-required debt, but they are not counted as stale. +- Explicit non-local unverified sources, such as BMJ documents with a `not a local WA source` evidence basis, remain labelled as unverified but are not treated as missing local-validation debt for release gating. +- Supporting top-5 review-required counts are reported to prioritize corpus review; they do not suppress ranking by themselves and are not the release gate. - Do not silently default missing corpus metadata to `current` or `approved`. -- Reduce the review-required rate by backfilling source metadata through ingestion/enrichment or by explicitly accepting a bounded review-required baseline in a versioned release metadata debt file. +- Reduce the warning rate by backfilling source metadata through ingestion/enrichment or by explicitly accepting the review-required baseline in a versioned release metadata debt file. - Danger-class source governance warnings are blocking. - Warning-class retrieval source metadata notes may be accepted only by passing `--source-metadata-debt ` to `npm run eval:quality -- --fail-on-threshold`. - Source metadata debt acceptance does not mark sources current or approved. It only removes the accepted retrieval metadata threshold failures from the blocking failure list. @@ -119,8 +122,8 @@ Answer quality: - retrieval hit@K is below `0.8` - document recall@5 is below `0.8` - content recall@5 is below `0.8` -- stale/outdated top-result rate is above `0.25` -- review-required top-result rate is above `0.25` +- primary top-result stale rate is above `0.25` +- primary top-result review-required rate is above `0.25` - grounded supported answer rate is below `0.9` - unsupported-answer correctness is below `1.0` - citation failure rate is above `0` @@ -154,4 +157,6 @@ Run full quality evals after: - clinical output changes - release or handoff confidence checks -`npm run verify:release` includes `npm run eval:quality:release` after cheaper local gates. `eval:quality:release` passes `docs/release-source-metadata-debt-2026-06-30.json` while that bounded release debt is active. Use `npm run audit:source-governance` to refresh the live corpus debt counts, and use focused variants such as `--retrieval-only`, `--rag-only`, `--limit`, `--query`, or `--question` during development to avoid unnecessary provider-backed cost. +`npm run governance:release` is the read-only governance routine. It runs generated label coverage, document label governance, and `audit:source-governance:release`. The source-governance audit compares the live corpus against `docs/release-source-metadata-debt-2026-06-30.json` and fails if required metadata returns, poor extraction appears, smart-v2 labels go missing, or the accepted eval baseline exceeds the file ceilings. + +`npm run verify:release` includes `npm run governance:release` before `npm run eval:quality:release`. `eval:quality:release` passes `docs/release-source-metadata-debt-2026-06-30.json` while that temporary release debt is active. The current release debt accepts primary review-required top-result rate only up to `0.2`; stale rate, outdated top results, poor-extraction top results, and danger-class source governance failures remain blocked. Use focused variants such as `--retrieval-only`, `--rag-only`, `--limit`, `--query`, or `--question` during development to avoid unnecessary provider-backed cost. diff --git a/docs/source-governance-priorities-2026-07-02.md b/docs/source-governance-priorities-2026-07-02.md new file mode 100644 index 0000000000..5cfbcf39b8 --- /dev/null +++ b/docs/source-governance-priorities-2026-07-02.md @@ -0,0 +1,38 @@ +# Source Governance Review Priorities - 2026-07-02 + +Source report: `output/evals/retrieval-quality-2026-07-02T06-55-32-323Z.json` + +## Current Gate + +- Retrieval recall: `top_k_hit_rate=1`, `document_recall_at_5=1`, `content_recall_at_5=1`. +- Primary source governance: `stale_rate=0`, `review_required_rate=0.1739`. +- Supporting top-5 source governance: `supporting_top5_review_required_rate=0.2655`. +- Explicit non-local unverified primary sources: `6`. These stay labelled as unverified but are not missing local-validation metadata debt. + +## Primary Release-Gated Debt + +Review these first because they appear as rank-1 primary results and drive the release governance rate. + +| Priority | Document | Status | Validation | Eval queries | +| -------- | ------------------------------------------------------------------------ | ------------ | ------------------------------------------- | ------------------------------------------------ | +| 1 | `Alcohol and Other Drugs - Addiction, Toxicity and Withdrawal (FSH).pdf` | `review_due` | `locally_reviewed` | `alcohol-ciwa-scoring`, `alcohol-ciwa-threshold` | +| 2 | `Alcohol withdrawal.pdf` | `review_due` | `unverified`, explicit non-local BMJ source | `alcohol-withdrawal-management` | +| 3 | `Schizoaffective disorder.pdf` | `review_due` | `unverified`, explicit non-local BMJ source | `bipolar-vs-schizoaffective` | + +## Supporting Top-5 Debt + +Review these next, ordered by repeated top-5 appearances. + +| Priority | Document | Count | Status | Validation | Eval queries | +| -------- | ------------------------------------------------------------------------ | ----: | ------------ | ----------------------------- | ------------------------------------------------------------ | +| 1 | `Clozapine Management by GP (NMHS).pdf` | 8 | `review_due` | `locally_reviewed` | `show-source-table-image`, `monitoring-threshold-from-chart` | +| 2 | `Alcohol and Other Drugs - Addiction, Toxicity and Withdrawal (FSH).pdf` | 5 | `review_due` | `locally_reviewed` | `alcohol-ciwa-scoring`, `alcohol-ciwa-threshold` | +| 3 | `Alcohol withdrawal.pdf` | 5 | `review_due` | explicit non-local BMJ source | `alcohol-withdrawal-management`, `alcohol-ciwa-threshold` | +| 4 | `Arousal and Agitation Drug Management (CAMHS).pdf` | 2 | `review_due` | `locally_reviewed` | `agitation-im-po-options`, `medication-chart-dose-route` | +| 5 | `Schizoaffective disorder.pdf` | 2 | `review_due` | explicit non-local BMJ source | `bipolar-vs-schizoaffective` | + +## Policy Notes + +- Do not mark review-due documents as current unless the source text or source owner confirms a current review/expiry date. +- Do not mark BMJ/non-local sources as locally reviewed. Keep the unverified/non-local label visible to users. +- Governance should not boost ranking. It should only apply small stale or poor-extraction safety penalties, while release gating and review prioritization happen in backend eval/audit routines. diff --git a/mockups/README.md b/mockups/README.md index 726846d139..e4a2d3bbe0 100644 --- a/mockups/README.md +++ b/mockups/README.md @@ -13,6 +13,11 @@ were removed in July 2026 so stale palettes do not mislead future design review - Medication prescribing now lives in the app at `/?mode=prescribing` and `/medications/acamprosate`. - `answer-evidence-popups/page.tsx` - copied from `src/app/mockups/answer-evidence-popups/page.tsx` +- `document-search` - runnable document-search mockup review board, in `src/app/mockups/document-search/page.tsx` +- `document-search/source` - live handoff route that resolves a mock result into `/documents/{id}?page=...&chunk=...`, in `src/app/mockups/document-search/source/page.tsx` +- `document-search-command` - runnable mockup only, in `src/app/mockups/document-search-command/page.tsx` +- `document-search-evidence-lens` - runnable mockup only, in `src/app/mockups/document-search-evidence-lens/page.tsx` +- `document-search-triage-board` - runnable mockup only, in `src/app/mockups/document-search-triage-board/page.tsx` - `mode-dropdown` - runnable mockup only, in `src/app/mockups/mode-dropdown/page.tsx` - `recent-searches-bottom` - runnable mockup only, in `src/app/mockups/recent-searches-bottom/page.tsx` - `settings-search-general` - runnable mockup only, in `src/app/mockups/settings-search-general/page.tsx` @@ -26,6 +31,11 @@ The runnable versions remain in the Next.js app route tree: - `/?mode=prescribing` - `/medications/acamprosate` - `/mockups/answer-evidence-popups` +- `/mockups/document-search?mode=documents` +- `/mockups/document-search/source?mode=documents&document=clozapine-monitoring&q=clozapine%20monitoring%20table&page=12&chunk=monitoring-table` +- `/mockups/document-search-command?mode=documents` +- `/mockups/document-search-evidence-lens?mode=documents` +- `/mockups/document-search-triage-board?mode=documents` - `/mockups/mode-dropdown` - `/mockups/recent-searches-bottom` - `/mockups/settings-search-general` @@ -43,3 +53,12 @@ New runnable mockups under `src/app/mockups/*` inherit the shared Clinical KB he - Use `?mode=answer`, `?mode=documents`, `?mode=prescribing`, `?mode=evidence`, or `?mode=favourites` to preview the active search mode. - The bottom composer routes live searches to the dashboard with `mode`, `q`, and `run=1`; New chat routes to `/?mode=answer&focus=1`. - If a future mockup must be standalone, move it outside the `/mockups` route shell or add an explicit opt-out route group before implementing it. + +## Synthetic document-search assets + +The document-search mockups use generated non-patient bitmap assets in `public/mockups/document-search/`. These images are +abstract UI/document textures only: they must not be treated as source screenshots, hospital-branded material, or clinical +content. + +The `document-search/source` route is the exception to the fixture-only mockup behavior: it is a local live handoff that +finds an indexed document and opens the existing document viewer with a selected page and chunk. diff --git a/package-lock.json b/package-lock.json index 6e6237c7f4..fbc7fff719 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "postgres": "3.4.9", "react": "19.2.7", "react-dom": "19.2.7", - "tesseract.js": "^7.0.0", "zod": "^4.4.3" }, "devDependencies": { @@ -4024,12 +4023,6 @@ "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, - "node_modules/bmp-js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", - "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", @@ -5940,12 +5933,6 @@ "node": ">=20.0.0" } }, - "node_modules/idb-keyval": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", - "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", - "license": "Apache-2.0" - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -6410,12 +6397,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "license": "MIT" - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -7421,26 +7402,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-releases": { "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", @@ -7633,15 +7594,6 @@ } } }, - "node_modules/opencollective-postinstall": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", - "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", - "license": "MIT", - "bin": { - "opencollective-postinstall": "index.js" - } - }, "node_modules/option": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", @@ -8381,12 +8333,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -9143,30 +9089,6 @@ "node": ">= 6" } }, - "node_modules/tesseract.js": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", - "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "bmp-js": "^0.1.0", - "idb-keyval": "^6.2.0", - "is-url": "^1.2.4", - "node-fetch": "^2.6.9", - "opencollective-postinstall": "^2.0.3", - "regenerator-runtime": "^0.13.3", - "tesseract.js-core": "^7.0.0", - "wasm-feature-detect": "^1.8.0", - "zlibjs": "^0.3.1" - } - }, - "node_modules/tesseract.js-core": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", - "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", - "license": "Apache-2.0" - }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -9270,12 +9192,6 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/traverse": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", @@ -9846,28 +9762,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/wasm-feature-detect": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", - "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", - "license": "Apache-2.0" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10077,15 +9971,6 @@ "node": ">= 6" } }, - "node_modules/zlibjs": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", - "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index b6397a2318..5dd0f81261 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node scripts/dev-free-port.mjs", "preinstall": "node scripts/check-node-engine.cjs", "ensure": "node scripts/ensure-local-server.mjs", - "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=16384 ./node_modules/next/dist/bin/next build --webpack", + "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack", "start": "node scripts/dev-free-port.mjs start", "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern", "typecheck": "node ./node_modules/typescript/bin/tsc --noEmit", @@ -24,7 +24,7 @@ "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "verify:cheap": "npm run check:runtime && npm run lint && npm run typecheck && npm run test", "verify:ui": "npm run check:runtime && npm run test:e2e:chromium", - "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run eval:quality:release", + "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", "ci:env-check": "node scripts/check-ci-env.mjs", "check:runtime": "tsx scripts/check-runtime.ts", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", @@ -40,8 +40,13 @@ "enrich:documents": "tsx scripts/enrich-documents.ts", "enrich:backfill": "tsx scripts/backfill-enrichment.ts", "classify:documents": "tsx scripts/classify-documents.ts", - "check:document-label-coverage": "tsx scripts/check-document-label-coverage.ts", "audit:source-governance": "tsx scripts/audit-source-governance.ts", + "audit:source-governance:release": "npm run audit:source-governance -- --debt-policy docs/release-source-metadata-debt-2026-06-30.json", + "governance:release": "npm run check:document-label-coverage && npm run check:document-label-governance && npm run audit:source-governance:release", + "check:document-label-coverage": "tsx scripts/check-document-label-coverage.ts", + "check:document-label-governance": "tsx scripts/check-document-label-governance.ts", + "backfill:gold-labels": "tsx scripts/backfill-gold-document-labels.ts", + "backfill:smart-v2-labels": "npm run classify:documents -- --all-owners --limit 5000 --only-missing-smart-v2", "tags:backfill": "tsx scripts/backfill-document-tags.ts", "index:backfill": "tsx scripts/backfill-smart-index.ts", "visual:backfill": "tsx scripts/backfill-visual-intelligence.ts", @@ -95,7 +100,6 @@ "postgres": "3.4.9", "react": "19.2.7", "react-dom": "19.2.7", - "tesseract.js": "^7.0.0", "zod": "^4.4.3" }, "overrides": { diff --git a/plugins/clinical-kb/.codex-plugin/plugin.json b/plugins/clinical-kb/.codex-plugin/plugin.json new file mode 100644 index 0000000000..67e4aaf1c4 --- /dev/null +++ b/plugins/clinical-kb/.codex-plugin/plugin.json @@ -0,0 +1,27 @@ +{ + "name": "clinical-kb", + "version": "0.1.0", + "description": "Repo guidance for Clinical KB, a Next.js clinical reference RAG app.", + "author": { + "name": "Clinical KB maintainers", + "url": "https://github.com/BigSimmo" + }, + "repository": "https://github.com/BigSimmo/Database", + "keywords": ["clinical-kb", "nextjs", "rag", "supabase", "clinical-safety", "local-development"], + "skills": "./skills/", + "interface": { + "displayName": "Clinical KB", + "shortDescription": "Work safely in the Clinical KB Next.js and Supabase RAG repo.", + "longDescription": "Clinical KB provides repo-specific Codex guidance for local server safety, Next.js 16 conventions, Supabase project safety, no-API verification, clinical governance checks, and focused verification choices.", + "developerName": "Clinical KB maintainers", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "websiteURL": "https://github.com/BigSimmo/Database", + "defaultPrompt": [ + "Run the safe Clinical KB local server.", + "Verify my Clinical KB change without API calls.", + "Review this Clinical KB RAG change." + ], + "brandColor": "#0B6F86" + } +} diff --git a/plugins/clinical-kb/README.md b/plugins/clinical-kb/README.md new file mode 100644 index 0000000000..1400ed16ab --- /dev/null +++ b/plugins/clinical-kb/README.md @@ -0,0 +1,27 @@ +# Clinical KB Codex Plugin + +This plugin follows the repo-local structure used by `openai/plugins`: + +- Marketplace: `.agents/plugins/marketplace.json` +- API-key-login marketplace: `.agents/plugins/api_marketplace.json` +- Plugin: `plugins/clinical-kb` +- Manifest: `plugins/clinical-kb/.codex-plugin/plugin.json` +- Skills: `plugins/clinical-kb/skills/` + +The plugin currently ships repo guidance only. It does not add MCP servers, app connectors, +commands, hooks, dependencies, or runtime code. + +Codex plugin discovery is marketplace-based. In a workspace marketplace, +`./plugins/clinical-kb` resolves to `C:\Dev\Apps\Database\plugins\clinical-kb`. +Restart Codex in this workspace if the plugin does not appear immediately. + +If your Codex install requires explicit local marketplace registration, add the repo root as +a plugin marketplace, then install the plugin by marketplace name: + +```powershell +codex plugin marketplace add C:\Dev\Apps\Database +codex plugin add clinical-kb@clinical-kb-local +``` + +For API-key-login marketplace flows, use `clinical-kb-api-local` as the marketplace name. +Start a new Codex thread after installing or updating the plugin so the skill list refreshes. diff --git a/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md b/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md new file mode 100644 index 0000000000..c5d96c54fc --- /dev/null +++ b/plugins/clinical-kb/skills/clinical-kb-workflow/SKILL.md @@ -0,0 +1,59 @@ +--- +name: clinical-kb-workflow +description: Use when working in the C:\Dev\Apps\Database Clinical KB repo, especially for local run, UI/browser QA, Supabase/OpenAI/RAG changes, clinical governance, dependency/upload shortcuts, or choosing verification. +--- + +# Clinical KB Workflow + +Use this skill for `C:\Dev\Apps\Database`, the Clinical KB Next.js clinical reference RAG app. +Root `AGENTS.md` remains authoritative. If these notes drift, inspect the repo before acting. + +## Repo Basics + +- App: Next.js 16, React 19, npm 11, Node 24. +- Package manager: npm with `package-lock.json`. +- Main app routes live under `src/app`; shared RAG, OpenAI, Supabase, safety, and validation logic live under `src/lib`. +- This project targets the live Supabase project `Clinical KB Database` with project ref `sjrfecxgysukkwxsowpy`. +- Treat the older Supabase ref `qjgitjyhxrwxsrydablr` as stale. + +## Local Server Safety + +- For a terse `run` request, run `npm run ensure` and return the printed URL. +- For UI, browser, screenshot, mobile, routing, or styling work, run `npm run ensure` before browser checks. +- Never assume `localhost:3000`, `localhost:3001`, or `localhost:3002`. +- Before attaching to an existing server, rely on the repo's local project identity guard, especially `/api/local-project-id` as used by `npm run ensure`. +- Do not kill another project's server. + +## OpenAI And API Cost Safety + +- Do not run OpenAI API-backed tasks, live evals, ingestion enrichment, embeddings, image captioning, or provider-backed answer generation unless the user explicitly asks for API usage. +- Prefer local/static/mocked checks. When a no-API guard is needed, clear `OPENAI_API_KEY`, `OPENAI_ORG_ID`, and `OPENAI_PROJECT_ID` for that command. +- Safe local server checks such as `npm run ensure` do not require provider calls. +- Broad browser aggregates can be slower or flaky in no-API mode; prefer focused Vitest or focused Chromium specs first. + +## Next.js Changes + +- This repo explicitly warns that its Next.js version has breaking changes. +- Before changing Next.js routes, server components, middleware, config, or build/runtime conventions, read the relevant guide under `node_modules/next/dist/docs/`. +- Do not rely on older Next.js assumptions when the local docs disagree. + +## Verification + +- For non-trivial source/config/test changes, prefer `npm run verify:cheap` as the first broad gate. +- For UI, frontend, browser, routing, styling, reduced-motion, or forced-colors changes, run `npm run ensure` first and use `npm run verify:ui` as the Chromium gate. +- For release or handoff confidence, use `npm run verify:release`. +- For clinical ingestion, answer generation, source governance, privacy, production-readiness, or environment changes, run the smallest relevant domain check plus `npm run check:production-readiness`. +- After Supabase env/config changes, run `npm run check:supabase-project`. +- Start from the smallest failing check and widen only after the focused failure is resolved. + +## Git And Worktree Safety + +- Preserve unrelated staged, unstaged, and untracked work. +- If starting from `main`, `master`, `develop`, or `release/*`, create a `codex/...` feature branch before editing when safe. +- Do not commit, push, force-push, reset, clean, merge into protected branches, or delete branches unless the user explicitly asks for that workflow. + +## Clinical Safety + +- This is a clinical reference prototype, not validated clinical decision support. +- Preserve source-backed answers, citations, document access controls, privacy boundaries, and fail-closed behavior on weak or unavailable evidence. +- For PRs touching ingestion, answer generation, search/ranking, source rendering, document access, privacy, production env, or clinical output, complete the clinical governance preflight in `.github/pull_request_template.md`. diff --git a/public/mockups/document-search/evidence-preview.png b/public/mockups/document-search/evidence-preview.png new file mode 100644 index 0000000000..4a2a278fcd Binary files /dev/null and b/public/mockups/document-search/evidence-preview.png differ diff --git a/public/mockups/document-search/source-stack.png b/public/mockups/document-search/source-stack.png new file mode 100644 index 0000000000..d3111cab7f Binary files /dev/null and b/public/mockups/document-search/source-stack.png differ diff --git a/public/mockups/document-search/triage-map.png b/public/mockups/document-search/triage-map.png new file mode 100644 index 0000000000..97c2dcb1a8 Binary files /dev/null and b/public/mockups/document-search/triage-map.png differ diff --git a/scripts/audit-source-governance.ts b/scripts/audit-source-governance.ts index 65e1598bb4..f4a1a7d81b 100644 --- a/scripts/audit-source-governance.ts +++ b/scripts/audit-source-governance.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; + import * as nextEnv from "@next/env"; import type { DocumentLabel } from "@/lib/types"; @@ -11,6 +13,28 @@ loadEnvConfig(process.cwd()); type AuditArgs = { json: boolean; help: boolean; + debtPolicyPath?: string; +}; + +type DebtPolicy = { + path: string; + accepted: boolean; + accepted_by: string; + accepted_at: string; + expires_at?: string; + ceilings: { + max_stale_rate: number; + max_review_required_rate: number; + max_outdated_top_results: number; + max_poor_extraction_top_results: number; + max_source_governance_danger_failure_rate: number; + }; + observed_retrieval_eval?: { + stale_rate?: number; + review_required_rate?: number; + stale_top_results?: number; + poor_extraction_top_results?: number; + }; }; type SupabaseAdmin = Awaited>; @@ -62,7 +86,8 @@ async function loadAdminClient() { function parseArgs(argv: string[]): AuditArgs { const args: AuditArgs = { json: false, help: false }; - for (const token of argv) { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; if (token === "--json") { args.json = true; continue; @@ -71,6 +96,13 @@ function parseArgs(argv: string[]): AuditArgs { args.help = true; continue; } + if (token === "--debt-policy") { + const value = argv[index + 1]; + if (!value) throw new Error("--debt-policy requires a path."); + args.debtPolicyPath = value; + index += 1; + continue; + } throw new Error(`Unknown option: ${token}`); } return args; @@ -83,8 +115,9 @@ function usage() { "Read-only audit of source governance metadata and smart-v2 label debt.", "", "Options:", - " --json Print machine-readable JSON.", - " --help Show this help.", + " --json Print machine-readable JSON.", + " --debt-policy Compare against a release source metadata debt file.", + " --help Show this help.", ].join("\n"); } @@ -141,12 +174,82 @@ function compactDocument(document: DocumentRow) { }; } +function asRecord(value: unknown, label: string) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value as Record; +} + +function requiredString(record: Record, key: string) { + const value = record[key]; + if (typeof value !== "string" || !value.trim()) throw new Error(`debt policy ${key} must be a non-empty string.`); + return value; +} + +function optionalString(record: Record, key: string) { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== "string" || !value.trim()) throw new Error(`debt policy ${key} must be a non-empty string.`); + return value; +} + +function requiredNumber(record: Record, key: string) { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`debt policy ${key} must be a number.`); + return value; +} + +async function loadDebtPolicy(path: string): Promise { + const parsed = JSON.parse(await readFile(path, "utf8")) as unknown; + const record = asRecord(parsed, "debt policy"); + const ceilings = asRecord(record.ceilings, "debt policy ceilings"); + const observedRetrievalEval = + record.observed_retrieval_eval === undefined + ? undefined + : asRecord(record.observed_retrieval_eval, "debt policy observed_retrieval_eval"); + + return { + path, + accepted: record.accepted === true, + accepted_by: requiredString(record, "accepted_by"), + accepted_at: requiredString(record, "accepted_at"), + expires_at: optionalString(record, "expires_at"), + ceilings: { + max_stale_rate: requiredNumber(ceilings, "max_stale_rate"), + max_review_required_rate: requiredNumber(ceilings, "max_review_required_rate"), + max_outdated_top_results: requiredNumber(ceilings, "max_outdated_top_results"), + max_poor_extraction_top_results: requiredNumber(ceilings, "max_poor_extraction_top_results"), + max_source_governance_danger_failure_rate: requiredNumber(ceilings, "max_source_governance_danger_failure_rate"), + }, + observed_retrieval_eval: observedRetrievalEval + ? { + stale_rate: + typeof observedRetrievalEval.stale_rate === "number" ? observedRetrievalEval.stale_rate : undefined, + review_required_rate: + typeof observedRetrievalEval.review_required_rate === "number" + ? observedRetrievalEval.review_required_rate + : undefined, + stale_top_results: + typeof observedRetrievalEval.stale_top_results === "number" + ? observedRetrievalEval.stale_top_results + : undefined, + poor_extraction_top_results: + typeof observedRetrievalEval.poor_extraction_top_results === "number" + ? observedRetrievalEval.poor_extraction_top_results + : undefined, + } + : undefined, + }; +} + async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } + const debtPolicy = args.debtPolicyPath ? await loadDebtPolicy(args.debtPolicyPath) : undefined; const supabase = await loadAdminClient(); const documents = await fetchAll(supabase, "documents", "id,title,file_name,status,metadata", (query) => @@ -186,6 +289,72 @@ async function main() { const missingGeneratedLabelDocuments = documents.filter((document) => !generatedLabelDocumentIds.has(document.id)); const missingSmartV2LabelDocuments = documents.filter((document) => !smartV2DocumentIds.has(document.id)); const requiredMetadataMissingTotal = [...requiredMissingCounts.values()].reduce((total, count) => total + count, 0); + const debtCounts = { + review_due: statusCounts.get("review_due") ?? 0, + unknown_status: statusCounts.get("unknown") ?? 0, + unverified_validation: validationCounts.get("unverified") ?? 0, + poor_extraction: extractionCounts.get("poor") ?? 0, + partial_extraction: extractionCounts.get("partial") ?? 0, + missing_smart_v2_labels: missingSmartV2LabelDocuments.length, + }; + const debtPolicyFailures: string[] = []; + + if (debtPolicy) { + if (!debtPolicy.accepted) debtPolicyFailures.push("debt policy must set accepted to true"); + const acceptedAt = Date.parse(debtPolicy.accepted_at); + if (!Number.isFinite(acceptedAt)) { + debtPolicyFailures.push(`debt policy accepted_at is invalid: ${debtPolicy.accepted_at}`); + } + if (debtPolicy.expires_at) { + const expiresAt = Date.parse(debtPolicy.expires_at); + if (!Number.isFinite(expiresAt)) { + debtPolicyFailures.push(`debt policy expires_at is invalid: ${debtPolicy.expires_at}`); + } else if (expiresAt < Date.now()) { + debtPolicyFailures.push(`debt policy expired at ${debtPolicy.expires_at}`); + } + } + if (requiredMetadataMissingTotal > 0) { + debtPolicyFailures.push(`required source metadata missing total ${requiredMetadataMissingTotal} must be 0`); + } + if (debtCounts.poor_extraction > debtPolicy.ceilings.max_poor_extraction_top_results) { + debtPolicyFailures.push( + `poor extraction documents ${debtCounts.poor_extraction} exceeds ceiling ${debtPolicy.ceilings.max_poor_extraction_top_results}`, + ); + } + if (debtCounts.missing_smart_v2_labels > 0) { + debtPolicyFailures.push(`missing smart-v2 labels ${debtCounts.missing_smart_v2_labels} must be 0`); + } + const observedEval = debtPolicy.observed_retrieval_eval; + if (observedEval?.stale_rate !== undefined && observedEval.stale_rate > debtPolicy.ceilings.max_stale_rate) { + debtPolicyFailures.push( + `accepted stale_rate ${observedEval.stale_rate} exceeds ceiling ${debtPolicy.ceilings.max_stale_rate}`, + ); + } + if ( + observedEval?.review_required_rate !== undefined && + observedEval.review_required_rate > debtPolicy.ceilings.max_review_required_rate + ) { + debtPolicyFailures.push( + `accepted review_required_rate ${observedEval.review_required_rate} exceeds ceiling ${debtPolicy.ceilings.max_review_required_rate}`, + ); + } + if ( + observedEval?.stale_top_results !== undefined && + observedEval.stale_top_results > debtPolicy.ceilings.max_outdated_top_results + ) { + debtPolicyFailures.push( + `accepted stale top results ${observedEval.stale_top_results} exceeds ceiling ${debtPolicy.ceilings.max_outdated_top_results}`, + ); + } + if ( + observedEval?.poor_extraction_top_results !== undefined && + observedEval.poor_extraction_top_results > debtPolicy.ceilings.max_poor_extraction_top_results + ) { + debtPolicyFailures.push( + `accepted poor extraction top results ${observedEval.poor_extraction_top_results} exceeds ceiling ${debtPolicy.ceilings.max_poor_extraction_top_results}`, + ); + } + } const report = { mode: "read-only", @@ -205,14 +374,7 @@ async function main() { documents_with_smart_v2_labels: smartV2DocumentIds.size, indexed_without_smart_v2_labels: missingSmartV2LabelDocuments.length, }, - debt_counts: { - review_due: statusCounts.get("review_due") ?? 0, - unknown_status: statusCounts.get("unknown") ?? 0, - unverified_validation: validationCounts.get("unverified") ?? 0, - poor_extraction: extractionCounts.get("poor") ?? 0, - partial_extraction: extractionCounts.get("partial") ?? 0, - missing_smart_v2_labels: missingSmartV2LabelDocuments.length, - }, + debt_counts: debtCounts, sample_review_due_documents: documents .filter((document) => metadataRecord(document.metadata).document_status === "review_due") .slice(0, 10) @@ -233,6 +395,18 @@ async function main() { })), indexed_document_id_count: indexedDocumentIds.size, passed_required_metadata_gate: requiredMetadataMissingTotal === 0, + debt_policy: debtPolicy + ? { + path: debtPolicy.path, + accepted_by: debtPolicy.accepted_by, + accepted_at: debtPolicy.accepted_at, + expires_at: debtPolicy.expires_at, + ceilings: debtPolicy.ceilings, + observed_retrieval_eval: debtPolicy.observed_retrieval_eval, + passed: debtPolicyFailures.length === 0, + failures: debtPolicyFailures, + } + : null, }; if (args.json) { @@ -274,9 +448,18 @@ async function main() { ? "PASS: required source governance metadata is complete." : "FAIL: required source governance metadata has gaps.", ); + if (report.debt_policy) { + console.log( + report.debt_policy.passed + ? `PASS: release debt policy accepted (${report.debt_policy.path}).` + : `FAIL: release debt policy rejected (${report.debt_policy.path}).`, + ); + for (const failure of report.debt_policy.failures) console.log(`- ${failure}`); + } } if (!report.passed_required_metadata_gate) process.exitCode = 1; + if (report.debt_policy && !report.debt_policy.passed) process.exitCode = 1; } main().catch((error) => { diff --git a/scripts/backfill-gold-document-labels.ts b/scripts/backfill-gold-document-labels.ts new file mode 100644 index 0000000000..e9efc044ad --- /dev/null +++ b/scripts/backfill-gold-document-labels.ts @@ -0,0 +1,275 @@ +import * as nextEnv from "@next/env"; +import { normalizeDocumentLabelForStorage } from "@/lib/document-tags"; +import type { Json } from "@/lib/supabase/database.types"; +import type { DocumentLabelType } from "@/lib/types"; + +const loadEnvConfig = + nextEnv.loadEnvConfig ?? + (nextEnv as unknown as { default?: { loadEnvConfig?: typeof nextEnv.loadEnvConfig } }).default?.loadEnvConfig; + +if (!loadEnvConfig) throw new Error("Unable to load @next/env loadEnvConfig."); +loadEnvConfig(process.cwd()); + +type Args = { + allOwners: boolean; + ownerId?: string; + limit: number; + write: boolean; + confirm: boolean; + help: boolean; +}; + +type SupabaseAdmin = Awaited>; + +type DocumentRow = { + id: string; + owner_id: string | null; + title: string; + file_name: string; + metadata: Record | null; +}; + +type LabelRow = { + id: string; + document_id: string; + owner_id: string | null; + label: string; + label_type: DocumentLabelType; + source: "generated" | "manual"; + confidence: number; + metadata: Record | null; +}; + +type SummaryRow = { + document_id: string; + summary: string | null; +}; + +type GoldLabelInsert = { + document_id: string; + owner_id: string | null; + label: string; + label_type: DocumentLabelType; + source: "manual"; + confidence: number; + metadata: Json; +}; + +async function invalidateRagCachesForAffectedOwners(supabase: SupabaseAdmin, ownerIds: Set) { + for (const ownerId of ownerIds) { + const deleteQuery = supabase.from("rag_response_cache").delete().in("cache_kind", ["search", "answer"]); + const { error } = ownerId ? await deleteQuery.eq("owner_id", ownerId) : await deleteQuery.is("owner_id", null); + if (error) throw new Error(error.message); + } +} + +async function loadAdminClient() { + const { createAdminClient } = await import("@/lib/supabase/admin"); + return createAdminClient(); +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + allOwners: false, + ownerId: process.env.RAG_EVAL_OWNER_ID ?? process.env.LOCAL_NO_AUTH_OWNER_ID, + limit: 5000, + write: false, + confirm: false, + help: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--all-owners") { + args.allOwners = true; + continue; + } + if (token === "--write") { + args.write = true; + continue; + } + if (token === "--confirm") { + args.confirm = true; + continue; + } + if (token === "--help" || token === "-h") { + args.help = true; + continue; + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); + index += 1; + if (token === "--owner-id") args.ownerId = value; + else if (token === "--limit") args.limit = Number(value); + else throw new Error(`Unknown option: ${token}`); + } + + if (!args.allOwners && !args.ownerId) throw new Error("Pass --owner-id or --all-owners."); + if (!Number.isInteger(args.limit) || args.limit <= 0) throw new Error("--limit must be positive."); + if (args.write && !args.confirm) throw new Error("Writing requires --write --confirm after reviewing a dry-run."); + return args; +} + +function usage() { + return [ + "Usage: npm run backfill:gold-labels -- [scope] [options]", + "", + "Backfills conservative high-value manual gold labels for indexed documents.", + "", + "Scopes:", + " --owner-id Backfill one owner.", + " --all-owners Backfill across all owners.", + "", + "Options:", + " --limit Max indexed documents to scan. Default: 5000.", + " --write --confirm Persist reviewed gold labels. Dry-run is the default.", + " --help Show this help.", + ].join("\n"); +} + +function chunkArray(items: T[], size: number) { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); + return chunks; +} + +async function loadDocuments(supabase: SupabaseAdmin, args: Args) { + const rows: DocumentRow[] = []; + const pageSize = 1000; + for (let offset = 0; offset < args.limit; offset += pageSize) { + let query = supabase + .from("documents") + .select("id,owner_id,title,file_name,metadata") + .eq("status", "indexed") + .order("id", { ascending: true }) + .range(offset, Math.min(offset + pageSize - 1, args.limit - 1)); + if (!args.allOwners && args.ownerId) query = query.eq("owner_id", args.ownerId); + const { data, error } = await query; + if (error) throw new Error(error.message); + rows.push(...((data ?? []) as DocumentRow[])); + if ((data ?? []).length < pageSize) break; + } + return rows; +} + +async function loadLabels(supabase: SupabaseAdmin, documentIds: string[]) { + const rows: LabelRow[] = []; + for (const ids of chunkArray(documentIds, 25)) { + const { data, error } = await supabase + .from("document_labels") + .select("id,document_id,owner_id,label,label_type,source,confidence,metadata") + .in("document_id", ids); + if (error) throw new Error(error.message); + rows.push(...((data ?? []) as LabelRow[])); + } + return rows; +} + +async function loadSummaries(supabase: SupabaseAdmin, documentIds: string[]) { + const rows: SummaryRow[] = []; + for (const ids of chunkArray(documentIds, 100)) { + const { data, error } = await supabase + .from("document_summaries") + .select("document_id,summary") + .in("document_id", ids); + if (error) throw new Error(error.message); + rows.push(...((data ?? []) as SummaryRow[])); + } + return rows; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + const supabase = await loadAdminClient(); + const [{ missingGoldLabelsForDocument }, documents] = await Promise.all([ + import("@/lib/document-label-governance"), + loadDocuments(supabase, args), + ]); + const documentIds = documents.map((document) => document.id); + const [labels, summaries] = await Promise.all([ + loadLabels(supabase, documentIds), + loadSummaries(supabase, documentIds), + ]); + const labelsByDocument = new Map(); + for (const label of labels) + labelsByDocument.set(label.document_id, [...(labelsByDocument.get(label.document_id) ?? []), label]); + const summariesByDocument = new Map(summaries.map((summary) => [summary.document_id, summary])); + const stampedAt = new Date().toISOString(); + + const inserts: GoldLabelInsert[] = []; + for (const document of documents) { + const missing = missingGoldLabelsForDocument({ + ...document, + labels: labelsByDocument.get(document.id) ?? [], + summary: summariesByDocument.get(document.id) ?? null, + }); + for (const label of missing) { + const normalized = normalizeDocumentLabelForStorage({ + label: label.label, + label_type: label.label_type, + confidence: 1, + source: "manual", + }); + if (!normalized) continue; + inserts.push({ + document_id: document.id, + owner_id: document.owner_id, + label: normalized.label, + label_type: normalized.label_type, + source: "manual", + confidence: 1, + metadata: { + curated_at: stampedAt, + curated_by: "gold-label-backfill", + curation_reason: label.reason, + review_status: "approved", + gold_label: true, + }, + }); + } + } + + const affectedDocuments = new Set(inserts.map((insert) => insert.document_id)); + console.log(`${args.write ? "WRITE" : "DRY-RUN"} gold document label backfill`); + console.log(`documents scanned: ${documents.length}`); + console.log(`documents needing gold labels: ${affectedDocuments.size}`); + console.log(`gold labels to upsert: ${inserts.length}`); + for (const insert of inserts.slice(0, 25)) { + const document = documents.find((row) => row.id === insert.document_id); + console.log(`- ${document?.title ?? insert.document_id}: ${insert.label_type}:${insert.label}`); + } + if (!args.write) { + console.log("\nNo writes performed. Re-run with --write --confirm after reviewing this output."); + return; + } + + let written = 0; + for (const batch of chunkArray(inserts, 500)) { + const { data, error } = await supabase + .from("document_labels") + .upsert(batch, { onConflict: "document_id,label_type,label,source" }) + .select("id"); + if (error) throw new Error(error.message); + if ((data ?? []).length !== batch.length) { + throw new Error(`gold label upsert expected ${batch.length} row(s), received ${(data ?? []).length}.`); + } + written += batch.length; + console.log(`Upserted ${written}/${inserts.length} gold label(s).`); + } + if (written > 0) { + await invalidateRagCachesForAffectedOwners(supabase, new Set(inserts.map((insert) => insert.owner_id))); + console.log( + `Invalidated RAG search/answer caches for ${new Set(inserts.map((insert) => insert.owner_id)).size} owner scope(s).`, + ); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/check-document-label-governance.ts b/scripts/check-document-label-governance.ts new file mode 100644 index 0000000000..c418edf4c8 --- /dev/null +++ b/scripts/check-document-label-governance.ts @@ -0,0 +1,191 @@ +import * as nextEnv from "@next/env"; +import type { DocumentLabelType } from "@/lib/types"; + +const loadEnvConfig = + nextEnv.loadEnvConfig ?? + (nextEnv as unknown as { default?: { loadEnvConfig?: typeof nextEnv.loadEnvConfig } }).default?.loadEnvConfig; + +if (!loadEnvConfig) throw new Error("Unable to load @next/env loadEnvConfig."); +loadEnvConfig(process.cwd()); + +type Args = { + json: boolean; + help: boolean; + sampleSize: number; + limit: number; +}; + +type SupabaseAdmin = Awaited>; + +type DocumentRow = { + id: string; + owner_id: string | null; + title: string; + file_name: string; + metadata: Record | null; +}; + +type LabelRow = { + id: string; + document_id: string; + owner_id: string | null; + label: string; + label_type: DocumentLabelType; + source: "generated" | "manual"; + confidence: number; + metadata: Record | null; +}; + +type SummaryRow = { + document_id: string; + summary: string | null; +}; + +async function loadAdminClient() { + const { createAdminClient } = await import("@/lib/supabase/admin"); + return createAdminClient(); +} + +function parseArgs(argv: string[]): Args { + const args: Args = { json: false, help: false, sampleSize: 100, limit: 5000 }; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--json") { + args.json = true; + continue; + } + if (token === "--help" || token === "-h") { + args.help = true; + continue; + } + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); + index += 1; + if (token === "--sample-size") args.sampleSize = Number(value); + else if (token === "--limit") args.limit = Number(value); + else throw new Error(`Unknown option: ${token}`); + } + if (!Number.isInteger(args.sampleSize) || args.sampleSize <= 0) throw new Error("--sample-size must be positive."); + if (!Number.isInteger(args.limit) || args.limit <= 0) throw new Error("--limit must be positive."); + return args; +} + +function usage() { + return [ + "Usage: npm run check:document-label-governance -- [options]", + "", + "Runs deterministic document-label analytics, QA sampling, gold-label coverage, and label relevance checks.", + "", + "Options:", + " --json Print machine-readable JSON.", + " --sample-size QA sample size. Default: 100.", + " --limit Max indexed documents to audit. Default: 5000.", + " --help Show this help.", + ].join("\n"); +} + +function chunkArray(items: T[], size: number) { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size)); + return chunks; +} + +async function loadDocuments(supabase: SupabaseAdmin, limit: number) { + const rows: DocumentRow[] = []; + const pageSize = 1000; + for (let offset = 0; offset < limit; offset += pageSize) { + const { data, error } = await supabase + .from("documents") + .select("id,owner_id,title,file_name,metadata") + .eq("status", "indexed") + .order("id", { ascending: true }) + .range(offset, Math.min(offset + pageSize - 1, limit - 1)); + if (error) throw new Error(error.message); + rows.push(...((data ?? []) as DocumentRow[])); + if ((data ?? []).length < pageSize) break; + } + return rows; +} + +async function loadLabels(supabase: SupabaseAdmin, documentIds: string[]) { + const rows: LabelRow[] = []; + for (const ids of chunkArray(documentIds, 25)) { + const { data, error } = await supabase + .from("document_labels") + .select("id,document_id,owner_id,label,label_type,source,confidence,metadata") + .in("document_id", ids); + if (error) throw new Error(error.message); + rows.push(...((data ?? []) as LabelRow[])); + } + return rows; +} + +async function loadSummaries(supabase: SupabaseAdmin, documentIds: string[]) { + const rows: SummaryRow[] = []; + for (const ids of chunkArray(documentIds, 100)) { + const { data, error } = await supabase + .from("document_summaries") + .select("document_id,summary") + .in("document_id", ids); + if (error) throw new Error(error.message); + rows.push(...((data ?? []) as SummaryRow[])); + } + return rows; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + const supabase = await loadAdminClient(); + const [{ buildDocumentLabelGovernanceReport }, documents] = await Promise.all([ + import("@/lib/document-label-governance"), + loadDocuments(supabase, args.limit), + ]); + const documentIds = documents.map((document) => document.id); + const [labels, summaries] = await Promise.all([ + loadLabels(supabase, documentIds), + loadSummaries(supabase, documentIds), + ]); + const labelsByDocument = new Map(); + for (const label of labels) + labelsByDocument.set(label.document_id, [...(labelsByDocument.get(label.document_id) ?? []), label]); + const summariesByDocument = new Map(summaries.map((summary) => [summary.document_id, summary])); + + const report = buildDocumentLabelGovernanceReport( + documents.map((document) => ({ + ...document, + labels: labelsByDocument.get(document.id) ?? [], + summary: summariesByDocument.get(document.id) ?? null, + })), + args.sampleSize, + ); + + if (args.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log("[Document Label Governance]"); + console.log(`Documents: ${report.analytics.documents}`); + console.log(`Labels: ${report.analytics.labelRows}`); + console.log(`Manual/generated: ${report.analytics.manual}/${report.analytics.generated}`); + console.log(`Hidden/approved: ${report.analytics.hidden}/${report.analytics.approved}`); + 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( + `Relevance checks: ${report.relevanceChecks.filter((check) => check.passed).length}/${report.relevanceChecks.length} passed`, + ); + console.log(report.passed ? "PASS: label governance checks passed." : "FAIL: label governance checks need review."); + } + + if (!report.passed) process.exitCode = 1; +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/classify-documents.ts b/scripts/classify-documents.ts index f102bc7220..fe9d1bde4b 100644 --- a/scripts/classify-documents.ts +++ b/scripts/classify-documents.ts @@ -1,5 +1,5 @@ import * as nextEnv from "@next/env"; -import { documentLabelTier } from "@/lib/document-tags"; +import { documentLabelTier, normalizeDocumentLabelForStorage } from "@/lib/document-tags"; const loadEnvConfig = nextEnv.loadEnvConfig ?? @@ -17,6 +17,7 @@ type ClassifyArgs = { write: boolean; confirm: boolean; help: boolean; + onlyMissingSmartV2: boolean; }; type SupabaseAdmin = Awaited>; @@ -43,8 +44,9 @@ type GeneratedLabelRow = { generated_by: "document-organization-classifier"; organization_profile_version: "document-organization-v1"; classified_at: string; - label_tier: ReturnType; - review_status: Classification["profile"]["review_status"]; + label_tier: string; + review_status: string; + [key: string]: unknown; }; }; @@ -68,6 +70,8 @@ const generatedLabelTypes = [ "content_feature", ] as const; +const smartV2GeneratedLabelTypes = new Set(["clinical_action", "care_phase", "document_intent", "content_feature"]); + async function loadAdminClient() { const { createAdminClient } = await import("@/lib/supabase/admin"); return createAdminClient(); @@ -82,6 +86,7 @@ function parseArgs(argv: string[]): ClassifyArgs { write: false, confirm: false, help: false, + onlyMissingSmartV2: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -102,6 +107,10 @@ function parseArgs(argv: string[]): ClassifyArgs { args.help = true; continue; } + if (token === "--only-missing-smart-v2") { + args.onlyMissingSmartV2 = true; + continue; + } const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`); @@ -135,6 +144,7 @@ function usage() { "Options:", " --limit Batch size for scoped runs. Default: 100.", " --offset Skip this many indexed documents before the batch. Default: 0.", + " --only-missing-smart-v2 Classify only loaded documents missing smart-v2 generated labels.", " --write --confirm Persist reviewed classifications. Dry-run is the default.", " --help, -h Show this help.", ].join("\n"); @@ -149,6 +159,7 @@ type ExistingGeneratedLabelRow = { document_id: string; label_type: string; label: string; + metadata: unknown; }; function assertMutationRows( @@ -177,6 +188,26 @@ function dedupeGeneratedLabels(rows: GeneratedLabelRow[]) { }); } +function preservedReviewMetadata(metadata: unknown) { + const record = metadataRecord(metadata); + const preserved: Record = {}; + for (const key of [ + "review_status", + "reviewed_at", + "reviewed_by", + "review_note", + "review_reason", + "hidden_at", + "hidden_by", + "hidden_reason", + "approved_at", + "approved_by", + ]) { + if (record[key] !== undefined) preserved[key] = record[key]; + } + return preserved; +} + function chunkArray(items: T[], size: number) { const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { @@ -213,6 +244,27 @@ async function loadDocuments(supabase: SupabaseAdmin, args: ClassifyArgs) { return documents; } +async function filterDocumentsMissingSmartV2(supabase: SupabaseAdmin, documents: DocumentRow[]) { + const smartV2LabelTypes = ["clinical_action", "care_phase", "document_intent", "content_feature"]; + const documentIds = documents.map((document) => document.id); + const documentsWithSmartV2 = new Set(); + + for (const ids of chunkArray(documentIds, 100)) { + const { data, error } = await supabase + .from("document_labels") + .select("document_id") + .in("document_id", ids) + .eq("source", "generated") + .in("label_type", smartV2LabelTypes); + if (error) throw new Error(error.message); + for (const label of data ?? []) { + if (typeof label.document_id === "string") documentsWithSmartV2.add(label.document_id); + } + } + + return documents.filter((document) => !documentsWithSmartV2.has(document.id)); +} + async function loadEvidenceText(supabase: SupabaseAdmin, documentId: string) { const [{ data: chunks, error: chunkError }, { data: summary, error: summaryError }] = await Promise.all([ supabase @@ -255,21 +307,61 @@ function generatedLabelsForPlan(plan: ClassificationPlan, stampedAt: string): Ge ].includes(label.label_type) && label.confidence >= 0.5, ); - return [...siteLabels, ...typeLabels, ...secondaryLabels].map((label) => ({ - document_id: plan.document.id, - owner_id: plan.document.owner_id, - label: label.label, - label_type: label.label_type, - confidence: label.confidence, - source: "generated", - metadata: { - generated_by: "document-organization-classifier", - organization_profile_version: "document-organization-v1", - classified_at: stampedAt, - label_tier: documentLabelTier(label.label, label.label_type), - review_status: plan.classification.profile.review_status, - }, - })); + const rows = [...siteLabels, ...typeLabels, ...secondaryLabels].flatMap((label) => { + const normalized = normalizeDocumentLabelForStorage({ + label: label.label, + label_type: label.label_type, + confidence: label.confidence, + source: "generated", + }); + if (!normalized) return []; + + return [ + { + document_id: plan.document.id, + owner_id: plan.document.owner_id, + label: normalized.label, + label_type: normalized.label_type, + confidence: normalized.confidence, + source: "generated" as const, + metadata: { + generated_by: "document-organization-classifier" as const, + organization_profile_version: "document-organization-v1" as const, + classified_at: stampedAt, + label_tier: documentLabelTier(normalized.label, normalized.label_type), + review_status: "new" as const, + }, + }, + ]; + }); + if (!rows.some((row) => smartV2GeneratedLabelTypes.has(row.label_type))) { + const fallbackIntent = + plan.classification.profile.document_type.label === "form" ? "documentation-requirement" : "staff-guidance"; + const normalized = normalizeDocumentLabelForStorage({ + label: fallbackIntent, + label_type: "document_intent", + confidence: 0.55, + source: "generated", + }); + if (normalized) { + rows.push({ + document_id: plan.document.id, + owner_id: plan.document.owner_id, + label: normalized.label, + label_type: normalized.label_type, + confidence: normalized.confidence, + source: "generated", + metadata: { + generated_by: "document-organization-classifier", + organization_profile_version: "document-organization-v1", + classified_at: stampedAt, + label_tier: documentLabelTier(normalized.label, normalized.label_type), + review_status: "new", + }, + }); + } + } + return rows; } async function writeClassifications(supabase: SupabaseAdmin, plans: ClassificationPlan[]) { @@ -315,7 +407,7 @@ async function writeClassifications(supabase: SupabaseAdmin, plans: Classificati const desiredLabelKeys = new Set(generatedLabels.map(labelIdentity)); const { data: existingGenerated, error: existingGeneratedError } = (await supabase .from("document_labels") - .select("id,document_id,label_type,label") + .select("id,document_id,label_type,label,metadata") .in("document_id", documentIds) .eq("source", "generated") .in("label_type", [...generatedLabelTypes])) as { @@ -327,8 +419,19 @@ async function writeClassifications(supabase: SupabaseAdmin, plans: Classificati const labelsToDelete = (existingGenerated ?? []) .filter((label) => !desiredLabelKeys.has(labelIdentity(label))) .map((label) => label.id); - - for (const labels of chunkArray(generatedLabels, labelUpsertBatchSize)) { + const existingGeneratedByKey = new Map((existingGenerated ?? []).map((label) => [labelIdentity(label), label])); + const labelsForUpsert = generatedLabels.map((label) => { + const preserved = preservedReviewMetadata(existingGeneratedByKey.get(labelIdentity(label))?.metadata); + return { + ...label, + metadata: { + ...label.metadata, + ...preserved, + }, + }; + }); + + for (const labels of chunkArray(labelsForUpsert, labelUpsertBatchSize)) { if (!labels.length) continue; const { data, error: labelError } = await supabase .from("document_labels") @@ -410,6 +513,16 @@ function printPlan(plans: ClassificationPlan[], write: boolean) { console.log(` site: ${site}; type: ${profile.document_type.label}; review: ${profile.review_status}`); if (profile.raw_bracket_tags.length) console.log(` bracket tags: ${profile.raw_bracket_tags.join(", ")}`); } + const needsReviewPlans = plans.filter((plan) => plan.classification.profile.review_status === "needs_review"); + if (needsReviewPlans.length > 0) { + console.log("\nNeeds review before write:"); + for (const plan of needsReviewPlans.slice(0, 25)) { + const profile = plan.classification.profile; + const site = + profile.site.label ?? (profile.site.candidates.map((candidate) => candidate.label).join(", ") || "none"); + console.log(`- ${plan.document.title}: site=${site}; type=${profile.document_type.label}`); + } + } if (!write) console.log("\nNo writes performed. Re-run with --write --confirm after reviewing this output."); } @@ -420,7 +533,10 @@ async function main() { return; } const supabase = await loadAdminClient(); - const documents = await loadDocuments(supabase, args); + const loadedDocuments = await loadDocuments(supabase, args); + const documents = args.onlyMissingSmartV2 + ? await filterDocumentsMissingSmartV2(supabase, loadedDocuments) + : loadedDocuments; const plans = []; for (const document of documents) { diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index d6b2a61a7c..52cbc50f2d 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -327,7 +327,7 @@ function evaluateSourceMetadataDebtAcceptance(args: { } const acceptedFailures = rejectionReasons.length === 0 ? metadataFailures : []; return { - status: acceptedFailures.length > 0 ? ("accepted" as const) : ("rejected" as const), + status: rejectionReasons.length > 0 ? ("rejected" as const) : ("accepted" as const), path: acceptance.path, accepted_by: acceptance.accepted_by, accepted_at: acceptance.accepted_at, diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index 04a54db3ff..0a4332f2f0 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -84,7 +84,12 @@ export type GoldenRetrievalResult = { page_number: number | null; document_status?: string | null; clinical_validation_status?: string | null; + clinical_validation_evidence_status?: string | null; + clinical_validation_evidence_basis?: string | null; + clinical_validation_evidence_type?: string | null; extraction_quality?: string | null; + publisher_code?: string | null; + jurisdiction?: string | null; hybrid_score: number | null; similarity: number; text_rank: number | null; @@ -375,22 +380,38 @@ function hasTableEvidence(results: SearchResult[], limit = 5) { } function topResultSummary(results: SearchResult[]) { - return results.slice(0, 5).map((result, index) => ({ - rank: index + 1, - title: result.title, - file_name: result.file_name, - chunk_id: result.id, - page_number: result.page_number, - document_status: result.source_metadata?.document_status ?? null, - clinical_validation_status: result.source_metadata?.clinical_validation_status ?? null, - extraction_quality: result.source_metadata?.extraction_quality ?? null, - hybrid_score: result.hybrid_score ?? null, - similarity: result.similarity, - text_rank: result.text_rank ?? null, - rrf_score: result.rrf_score ?? null, - score_explanation: result.score_explanation, - content_preview: (result.retrieval_synopsis || result.content).replace(/\s+/g, " ").trim().slice(0, 220), - })); + return results.slice(0, 5).map((result, index) => { + const validationEvidence = + result.source_metadata?.clinical_validation_evidence && + typeof result.source_metadata.clinical_validation_evidence === "object" && + !Array.isArray(result.source_metadata.clinical_validation_evidence) + ? (result.source_metadata.clinical_validation_evidence as Record) + : {}; + return { + rank: index + 1, + title: result.title, + file_name: result.file_name, + chunk_id: result.id, + page_number: result.page_number, + document_status: result.source_metadata?.document_status ?? null, + clinical_validation_status: result.source_metadata?.clinical_validation_status ?? null, + clinical_validation_evidence_status: + typeof validationEvidence.status === "string" ? validationEvidence.status : null, + clinical_validation_evidence_basis: + typeof validationEvidence.basis === "string" ? validationEvidence.basis : null, + clinical_validation_evidence_type: + typeof validationEvidence.evidence_type === "string" ? validationEvidence.evidence_type : null, + extraction_quality: result.source_metadata?.extraction_quality ?? null, + publisher_code: result.source_metadata?.publisher_code ?? null, + jurisdiction: result.source_metadata?.jurisdiction ?? null, + hybrid_score: result.hybrid_score ?? null, + similarity: result.similarity, + text_rank: result.text_rank ?? null, + rrf_score: result.rrf_score ?? null, + score_explanation: result.score_explanation, + content_preview: (result.retrieval_synopsis || result.content).replace(/\s+/g, " ").trim().slice(0, 220), + }; + }); } export function evaluateGoldenRetrievalCase(args: { diff --git a/scripts/run-playwright.mjs b/scripts/run-playwright.mjs index 190ca3dea3..6d1c6d5af0 100644 --- a/scripts/run-playwright.mjs +++ b/scripts/run-playwright.mjs @@ -16,6 +16,8 @@ const playwrightBin = path.join(projectRoot, "node_modules", "playwright", "cli. const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next"); const identityPath = "/api/local-project-id"; const startupTimeoutMs = 120_000; +const missingErrorComponentsNeedle = "missing required error components"; +const routeSmokePaths = ["/", "/applications"]; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -91,11 +93,45 @@ function requestJson(url) { }); } +function requestText(url, timeoutMs = 30_000) { + return new Promise((resolve) => { + const request = http.get(url, { timeout: timeoutMs }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + body += chunk; + }); + response.on("end", () => { + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) { + resolve(null); + return; + } + resolve(body); + }); + }); + request.on("timeout", () => { + request.destroy(); + resolve(null); + }); + request.on("error", () => resolve(null)); + }); +} + +async function hasHealthyRouteComponents(baseUrl) { + for (const smokePath of routeSmokePaths) { + const body = await requestText(`${baseUrl}${smokePath}`); + if (!body || body.includes(missingErrorComponentsNeedle)) { + return false; + } + } + return true; +} + async function waitForServer(baseUrl) { const startedAt = Date.now(); while (Date.now() - startedAt < startupTimeoutMs) { const payload = await requestJson(`${baseUrl}${identityPath}`); - if (isVerifiedProjectPayload(payload)) { + if (isVerifiedProjectPayload(payload) && (await hasHealthyRouteComponents(baseUrl))) { return; } await sleep(500); @@ -169,9 +205,15 @@ foreach ($target in $targets) { const existingBaseUrl = await findExistingProjectServer(); if (existingBaseUrl) { - console.log(`Using existing Clinical KB server at ${existingBaseUrl}`); - const result = runPlaywright(existingBaseUrl); - process.exit(result.status ?? (result.signal ? 1 : 0)); + if (await hasHealthyRouteComponents(existingBaseUrl)) { + console.log(`Using existing Clinical KB server at ${existingBaseUrl}`); + const result = runPlaywright(existingBaseUrl); + process.exit(result.status ?? (result.signal ? 1 : 0)); + } + + console.log(`Existing Clinical KB server at ${existingBaseUrl} failed route-component smoke; restarting it.`); + stopExistingProjectDevServers(); + await sleep(1000); } stopExistingProjectDevServers(); diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index ffcb91cbe4..dde68684b6 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -38,6 +38,13 @@ const manualLabelUpdateSchema = manualLabelSchema.extend({ labelId: z.string().uuid(), }); +const labelReviewSchema = z.object({ + labelId: z.string().uuid(), + action: z.enum(["approve", "hide", "restore"]), +}); + +const labelPatchSchema = z.union([manualLabelUpdateSchema, labelReviewSchema]); + const manualLabelDeleteSchema = z.object({ labelId: z.string().uuid(), }); @@ -58,6 +65,10 @@ function parseManualLabel(input: z.infer) { return normalized; } +function metadataRecord(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; +} + async function requireOwnedDocument( supabase: ReturnType, documentId: string, @@ -152,17 +163,49 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 }); } - const parsed = await parseJsonBody( - request, - manualLabelUpdateSchema, - "Enter a manual tag between 2 and 64 characters.", - ); - const normalized = parseManualLabel(parsed); + const parsed = await parseJsonBody(request, labelPatchSchema, "Enter a manual tag or label review action."); const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); await requireOwnedDocument(supabase, id, user.id); + if ("action" in parsed) { + const { data: existing, error: existingError } = await supabase + .from("document_labels") + .select("id,metadata") + .eq("id", parsed.labelId) + .eq("document_id", id) + .eq("owner_id", user.id) + .maybeSingle(); + + if (existingError) throw new Error(existingError.message); + if (!existing) throw new PublicApiError("Tag not found.", 404); + + const reviewStatus = parsed.action === "approve" ? "approved" : parsed.action === "hide" ? "hidden" : "new"; + const metadata = { + ...metadataRecord(existing.metadata), + review_status: reviewStatus, + hidden: parsed.action === "hide", + reviewed_at: new Date().toISOString(), + reviewed_by: "label-review-admin", + }; + + const { data: label, error } = await supabase + .from("document_labels") + .update({ metadata }) + .eq("id", parsed.labelId) + .eq("document_id", id) + .eq("owner_id", user.id) + .select("*") + .single(); + + if (error) throw new Error(error.message); + invalidateRagCachesForDocumentMutation(user.id); + return NextResponse.json({ label }); + } + + const normalized = parseManualLabel(parsed); + const { data: existing, error: existingError } = await supabase .from("document_labels") .select("id,metadata") diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000000..20cf31bf6b --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; +import { primaryControl } from "@/components/ui-primitives"; +import { cn } from "@/components/ui-primitives"; + +export default function ErrorBoundary({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + // Log the error to an error reporting service + console.error("Unhandled runtime error captured by boundary:", error); + }, [error]); + + return ( +
+
+
+ +
+ +

+ Something went wrong +

+ +

+ An unexpected error occurred in the application shell. You can try to reset the current view or refresh the + browser. +

+ + {error.digest && ( +
+ Digest: {error.digest} +
+ )} + +
+ + + +
+
+
+ ); +} diff --git a/src/app/mockups/document-search-command/page.tsx b/src/app/mockups/document-search-command/page.tsx new file mode 100644 index 0000000000..1d6af02075 --- /dev/null +++ b/src/app/mockups/document-search-command/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DocumentSearchMockupPage } from "@/components/document-search-mockups"; + +export const metadata: Metadata = { + title: "Document Search Command Mockup - Clinical KB", + description: "Production-candidate document search command center mockup for Clinical KB.", +}; + +export default function DocumentSearchCommandMockupRoute() { + return ; +} diff --git a/src/app/mockups/document-search-evidence-lens/page.tsx b/src/app/mockups/document-search-evidence-lens/page.tsx new file mode 100644 index 0000000000..b23844c34d --- /dev/null +++ b/src/app/mockups/document-search-evidence-lens/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DocumentSearchMockupPage } from "@/components/document-search-mockups"; + +export const metadata: Metadata = { + title: "Document Search Evidence Lens Mockup - Clinical KB", + description: "Document search evidence lens mockup with selected source proof in view.", +}; + +export default function DocumentSearchEvidenceLensMockupRoute() { + return ; +} diff --git a/src/app/mockups/document-search-triage-board/page.tsx b/src/app/mockups/document-search-triage-board/page.tsx new file mode 100644 index 0000000000..959f58f66c --- /dev/null +++ b/src/app/mockups/document-search-triage-board/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DocumentSearchMockupPage } from "@/components/document-search-mockups"; + +export const metadata: Metadata = { + title: "Document Search Triage Board Mockup - Clinical KB", + description: "Discovery-first document library triage board mockup for Clinical KB.", +}; + +export default function DocumentSearchTriageBoardMockupRoute() { + return ; +} diff --git a/src/app/mockups/document-search/page.tsx b/src/app/mockups/document-search/page.tsx new file mode 100644 index 0000000000..24e9f9d2b1 --- /dev/null +++ b/src/app/mockups/document-search/page.tsx @@ -0,0 +1,157 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { Metadata } from "next"; +import { ArrowRight, FileText, Search, ShieldCheck, Sparkles } from "lucide-react"; + +import { cn } from "@/components/ui-primitives"; + +export const metadata: Metadata = { + title: "Document Search Mockups - Clinical KB", + description: "Three runnable document-search UX concepts for Clinical KB document mode.", +}; + +const concepts = [ + { + href: "/mockups/document-search-command?mode=documents", + eyebrow: "Production candidate", + title: "Command center", + body: "Compact search, sort, result rows, and an active source preview for fast document lookup.", + image: "/mockups/document-search/source-stack.png", + alt: "Synthetic layered document stack with highlighted abstract source regions.", + icon: Search, + priorities: ["Fast scan", "Sort clarity", "Pinned preview"], + }, + { + href: "/mockups/document-search-evidence-lens?mode=documents", + eyebrow: "Evidence lens", + title: "Source proof in view", + body: "A split workbench that keeps the selected page, table, image, and ranking explanation together.", + image: "/mockups/document-search/evidence-preview.png", + alt: "Synthetic source page connected to abstract table, image, and warning evidence panels.", + icon: ShieldCheck, + priorities: ["Preview first", "Why this result", "Exact evidence"], + }, + { + href: "/mockups/document-search-triage-board?mode=documents", + eyebrow: "Discovery board", + title: "Library triage", + body: "A document-mode home for recent sources, source health, smart facets, and status lanes.", + image: "/mockups/document-search/triage-map.png", + alt: "Synthetic document triage board with abstract grouped source cards and status lanes.", + icon: Sparkles, + priorities: ["Recent work", "Source health", "Facet discovery"], + }, +] as const; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +function Pill({ children, active = false }: { children: string; active?: boolean }) { + return ( + + {children} + + ); +} + +export default function DocumentSearchMockupsIndexRoute() { + return ( +
+
+
+
+
+
+ + +

+ Document mode UX +

+
+

+ Three runnable document search directions +

+

+ This page is the review board. Each direction below opens as its own full runnable mockup inside the + shared Clinical KB header and document-mode bottom composer. +

+
+
+

+ Open a direction +

+
+ Command + Evidence lens + Triage board +
+
+
+
+ +
+ {concepts.map((concept, index) => { + const Icon = concept.icon; + return ( + +
+ {concept.alt} +
+
+
+
+ + +

+ {concept.eyebrow} +

+
+

+ {concept.title} +

+

{concept.body}

+
+ {concept.priorities.map((priority, priorityIndex) => ( + + {priority} + + ))} +
+
+ + Open full mockup + + +
+ + ); + })} +
+
+
+ ); +} diff --git a/src/app/mockups/document-search/source/page.tsx b/src/app/mockups/document-search/source/page.tsx new file mode 100644 index 0000000000..3bfaaa123b --- /dev/null +++ b/src/app/mockups/document-search/source/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; + +import { DocumentSearchLiveOpener } from "@/components/document-search-live-opener"; + +export const metadata: Metadata = { + title: "Open Highlighted Document - Clinical KB", + description: "Resolves a document-search mockup result to the live document viewer with a selected source chunk.", +}; + +export default function HighlightedDocumentSearchSourceRoute() { + return ( + + + + ); +} diff --git a/src/components/AccessibleTable.tsx b/src/components/AccessibleTable.tsx index 340d005f85..49bda56177 100644 --- a/src/components/AccessibleTable.tsx +++ b/src/components/AccessibleTable.tsx @@ -335,7 +335,9 @@ export function AccessibleTable({ const [open, setOpen] = useState(false); const canExpand = useMobileTableExpansion(expandOnMobile); const hasExplicitRows = Boolean(rows?.length); - const parsed = hasExplicitRows ? rows : parseMarkdownTable(markdown); + const parsed = useMemo(() => { + return hasExplicitRows ? rows : parseMarkdownTable(markdown); + }, [hasExplicitRows, rows, markdown]); const normalized = useMemo(() => { if (!parsed?.length) return null; // Audit M8/H4 parity (diff review): markdown-parsed rows include their diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index bff397a83d..cb685f7379 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; +import dynamic from "next/dynamic"; import { Activity, AlertCircle, @@ -54,6 +55,7 @@ import { type CSSProperties, FormEvent, memo, + type RefObject, useCallback, useEffect, useMemo, @@ -156,10 +158,27 @@ import { } from "@/components/clinical-dashboard/display-text"; import { MasterSearchHeader } from "@/components/clinical-dashboard/master-search-header"; import { emptyStates, errorCopy } from "@/lib/ui-copy"; -import { DifferentialsHome } from "@/components/clinical-dashboard/differentials-home"; -import { FavouritesHub } from "@/components/clinical-dashboard/favourites-hub"; -import { MedicationPrescribingWorkspace } from "@/components/clinical-dashboard/medication-prescribing-workspace"; -import { ApplicationsLauncherWorkspace, applicationsLauncherItemCount } from "@/components/applications-launcher-page"; +import { applicationsLauncherItemCount } from "@/components/applications-launcher-page"; + +const DifferentialsHome = dynamic( + () => import("@/components/clinical-dashboard/differentials-home").then((m) => m.DifferentialsHome), + { ssr: false }, +); +const FavouritesHub = dynamic( + () => import("@/components/clinical-dashboard/favourites-hub").then((m) => m.FavouritesHub), + { ssr: false }, +); +const MedicationPrescribingWorkspace = dynamic( + () => + import("@/components/clinical-dashboard/medication-prescribing-workspace").then( + (m) => m.MedicationPrescribingWorkspace, + ), + { ssr: false }, +); +const ApplicationsLauncherWorkspace = dynamic( + () => import("@/components/applications-launcher-page").then((m) => m.ApplicationsLauncherWorkspace), + { ssr: false }, +); import { DocumentSearchResultsPanel, MatchExplanationChips, @@ -215,10 +234,15 @@ import { } from "@/lib/source-governance"; import { smartEvidenceTags } from "@/lib/evidence-tags"; import { + documentLabelReviewStatus, + documentLabelTier, + formatDocumentLabelDisplay, + normalizeDocumentLabelForStorage, reviewDocumentTagQuality, tagSearchText, type SmartDocumentTag, type SmartDocumentTagFacet, + type SmartDocumentTagTier, type SmartDocumentTagQualityIssueKind, } from "@/lib/document-tags"; import type { @@ -239,6 +263,8 @@ import type { SearchScopeSummary, VisualEvidenceCard, ClinicalQueryMode, + DocumentLabel, + DocumentLabelType, } from "@/lib/types"; import type { SearchScopeFilters } from "@/lib/search-scope"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; @@ -711,7 +737,7 @@ function sourceCapsuleText({ if (sourceCount <= 0) return "No direct source found"; if (!grounded) return "Review nearby sources"; if (weakEvidence) return "Review sources"; - return `Source-backed · ${sourceCount} source${sourceCount === 1 ? "" : "s"}`; + return `${sourceCount} source${sourceCount === 1 ? "" : "s"}`; } function sourceStatusDotClass(metadata: ReturnType | null | undefined) { @@ -729,8 +755,45 @@ type CapsulePreviewSource = { score: number; href: string; snippet?: string; + sourceStrength?: + SourceLink["sourceStrength"] | BestSourceRecommendation["source_strength"] | SearchResult["source_strength"]; }; +function sourceBadgeLabel(index: number) { + return `S${index + 1}`; +} + +function sourceBadgeToneClass(metadata: ReturnType, index: number) { + if (metadata.document_status === "review_due" || metadata.document_status === "outdated") { + return "border-[color:var(--warning-border)] bg-[color:var(--warning-soft)] text-[color:var(--warning)]"; + } + if (index === 0) { + return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)]"; + } + return "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; +} + +function sourceSupportLabel(source: CapsulePreviewSource, index: number) { + if (!source.sourceStrength || source.sourceStrength === "none") return "Unsupported"; + if (source.sourceStrength === "limited") return "Partial"; + if (source.sourceStrength === "moderate") return "Partial"; + if (index === 0 || source.sourceStrength === "strong") return "Direct"; + return "Partial"; +} + +function sourceStatusShortLabel(metadata: ReturnType) { + if (metadata.document_status === "review_due") return "Review due"; + if (metadata.document_status === "outdated") return "Outdated"; + if (metadata.document_status === "current") return "Current"; + return sourceStatusLabel(metadata); +} + +function sourcePreviewPageCountLabel(previewSources: CapsulePreviewSource[]) { + const uniquePages = new Set(previewSources.map((source) => source.pageNumber).filter((page) => page !== null)); + const count = uniquePages.size || previewSources.length; + return `${count} page${count === 1 ? "" : "s"}`; +} + function capsulePreviewSources( bestSource: BestSourceRecommendation | null, sources: SearchResult[], @@ -754,6 +817,7 @@ function capsulePreviewSources( score: source.score ?? 0, href: source.href, snippet: source.snippet, + sourceStrength: source.sourceStrength, }); }); @@ -765,6 +829,7 @@ function capsulePreviewSources( metadata: normalizeSourceMetadata(bestSource.source_metadata), score: bestSource.score, href: bestSource.viewer_href, + sourceStrength: bestSource.source_strength, }); } @@ -776,10 +841,11 @@ function capsulePreviewSources( metadata: normalizeSourceMetadata(source.source_metadata), score: source.hybrid_score ?? source.similarity ?? source.lexical_score ?? 0, href: sourceResultHref(source), + sourceStrength: source.source_strength, }); }); - return rows.slice(0, 3); + return rows.slice(0, 4); } function SourcePreviewContent({ @@ -787,51 +853,106 @@ function SourcePreviewContent({ quoteText, copiedQuote, onCopyQuote, + showHeader = true, }: { previewSources: CapsulePreviewSource[]; quoteText?: string | null; copiedQuote: boolean; onCopyQuote: () => void; + showHeader?: boolean; }) { const primaryPreviewSource = previewSources[0] ?? null; + const reviewDueSource = previewSources.find( + (source) => source.metadata.document_status === "review_due" || source.metadata.document_status === "outdated", + ); return ( <> -
-
-

- Sources behind this answer -

-

- Preview first, then open the source document when needed. -

+ {showHeader ? ( +
+
+
+

Sources

+ + {sourcePreviewPageCountLabel(previewSources)} + +
+

Open the original PDF page.

+
- {previewSources.length} sources -
-
+ ) : null} +
{previewSources.map((source, index) => ( - -
))}
{quoteText ? ( @@ -857,6 +978,28 @@ function SourcePreviewContent({ ) : null}
+
+ + {reviewDueSource ? : } + {reviewDueSource + ? `${sourceBadgeLabel(previewSources.indexOf(reviewDueSource))} review due` + : "Sources current"} + + {primaryPreviewSource ? ( + + Evidence details + + + ) : null} +
); } @@ -985,8 +1128,13 @@ function NaturalLanguageAnswer({ setSourcePreviewOpen(false)} - title="Sources behind this answer" - description="Preview sources first, then open the source document when needed." + title="Sources" + description="Open the original PDF page." + titleAccessory={ + + {sourcePreviewPageCountLabel(previewSources)} + + } closeLabel="Close answer sources" contentClassName="sm:max-w-xl" returnFocusRef={sourceCapsuleRef} @@ -998,6 +1146,7 @@ function NaturalLanguageAnswer({ quoteText={quoteText} copiedQuote={copiedSourceQuote} onCopyQuote={copySourceQuote} + showHeader={false} /> @@ -1129,6 +1278,158 @@ function KeyClinicalItems({ ); } +type AnswerSupportPriority = { + title: string; + detail: string; + sourceLabel?: string; + tone: "priority" | "caution"; +}; + +function answerSupportPriority( + answer: RagAnswer, + sections: Array, + table: VisualEvidenceCard | null, + safetyFindings: ReturnType, + options: { grounded: boolean; weakEvidence: boolean }, +): AnswerSupportPriority | null { + const firstSafetyFinding = safetyFindings[0]; + if (firstSafetyFinding) { + return { + title: "Priority", + detail: formatSafetyFindingLabel(firstSafetyFinding), + sourceLabel: "S1", + tone: "caution", + }; + } + + if (answer.answerQualityTier === "source_only" || !options.grounded || options.weakEvidence) { + return { + title: "Review source match", + detail: + "Verify cited passages before using clinical numbers, monitoring, dose, route, timing, or risk decisions.", + sourceLabel: "Review", + tone: "caution", + }; + } + + const sectionItems = keyClinicalItemsFromSections(sections); + const tableItems = keyClinicalItemsFromTable(table); + const item = sectionItems[0] ?? tableItems[0] ?? null; + if (!item) return null; + + return { + title: item.label ?? "Priority", + detail: item.detail, + sourceLabel: "S1", + tone: "priority", + }; +} + +function AnswerSupportSummaryCard({ + priority, + clinicalCount, + evidenceSummary, + clinicalAvailable, + evidenceAvailable, + clinicalTriggerRef, + evidenceTriggerRef, + onOpenClinicalNotes, + onOpenEvidence, +}: { + priority: AnswerSupportPriority | null; + clinicalCount: number; + evidenceSummary: string; + clinicalAvailable: boolean; + evidenceAvailable: boolean; + clinicalTriggerRef?: RefObject; + evidenceTriggerRef?: RefObject; + onOpenClinicalNotes: () => void; + onOpenEvidence: () => void; +}) { + const supportRowCount = Number(clinicalAvailable) + Number(evidenceAvailable); + const supportButtonClass = + "grid min-h-[72px] grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 px-3 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)]"; + + return ( +
+ {priority ? ( +
+ +
+

{priority.title}

+

{priority.detail}

+
+ {priority.sourceLabel ? ( + {priority.sourceLabel} + ) : null} +
+ ) : null} + + {supportRowCount > 0 ? ( +
+ {clinicalAvailable ? ( + + ) : null} + {evidenceAvailable ? ( + + ) : null} +
+ ) : null} +
+ ); +} + function comparableAnswerText(value: string) { return value .replace(/\*\*/g, "") @@ -1263,7 +1564,7 @@ function clinicalDetailSummaryItems(sections: ClinicalDetailSection[]) { return items.filter((item) => item.value > 0); } -type ClinicalNotesTabId = "safety" | "monitor"; +type ClinicalNotesTabId = "essentials" | "actions" | "safety"; type ClinicalNotesRow = { id: string; @@ -1277,16 +1578,21 @@ const clinicalNotesTabMeta: Record< ClinicalNotesTabId, { label: string; icon: typeof ShieldCheck; sectionIds: string[] } > = { + essentials: { + label: "Essentials", + icon: ClipboardCheck, + sectionIds: ["thresholds", "monitoring", "medication", "support-map", "comparison"], + }, + actions: { + label: "Actions", + icon: Activity, + sectionIds: ["action", "documentation", "monitoring", "medication"], + }, safety: { label: "Safety", icon: ShieldCheck, sectionIds: ["escalation", "cautions", "source-gap", "thresholds"], }, - monitor: { - label: "Monitor", - icon: Activity, - sectionIds: ["monitoring", "medication", "action"], - }, }; function compactClinicalNoteText(value: string) { @@ -1485,8 +1791,15 @@ function clinicalNotesRowsForTab(sections: ClinicalDetailSection[], tab: Clinica for (const section of sections) { const sectionText = `${section.title} ${section.items.join(" ")}`.toLowerCase(); const hasMonitoringText = - tab === "monitor" && /\b(monitor|screen|level|fbc|anc|metabolic|renal|thyroid|function)\b/i.test(sectionText); + (tab === "actions" || tab === "essentials") && + /\b(monitor|screen|level|fbc|anc|metabolic|renal|thyroid|function)\b/i.test(sectionText); + const hasSafetyText = + tab === "safety" && + /\b(toxicity|toxic|urgent|caution|contraindication|red flag|escalat|warning|review due)\b/i.test(sectionText); if (!meta.sectionIds.includes(section.id) && !hasMonitoringText) { + if (!hasSafetyText) continue; + } + if (tab === "essentials" && section.id === "action" && rows.length >= 2) { continue; } const tone: ClinicalNotesRow["tone"] = section.id === "escalation" || section.id === "cautions" ? "warn" : "safe"; @@ -1567,27 +1880,14 @@ function ClinicalNotesChecklistPanel({ }) { const detailSections = clinicalNotesDetailSectionsForAnswer(answer, viewMode); const tabs = clinicalNotesAvailableTabs(detailSections); - const [requestedTab, setRequestedTab] = useState(tabs[0]?.id ?? "safety"); - const activeTab = tabs.some((tab) => tab.id === requestedTab) ? requestedTab : (tabs[0]?.id ?? "safety"); + const defaultTab = tabs.find((tab) => tab.id === "actions")?.id ?? tabs[0]?.id ?? "actions"; + const [requestedTab, setRequestedTab] = useState(defaultTab); + const activeTab = tabs.some((tab) => tab.id === requestedTab) ? requestedTab : defaultTab; const rows = clinicalNotesRowsForTab(detailSections, activeTab); const tableEvidenceCount = clinicalNotesTableEvidenceCount(answer); - const [expandedRowId, setExpandedRowId] = useState(null); - const firstExpandableRow = rows.find(clinicalNoteHasDistinctDetail) ?? null; - const activeRow = rows.find((row) => row.id === expandedRowId) ?? firstExpandableRow; const [added, setAdded] = useState(false); - const warningCount = rows.filter((row) => row.tone === "warn").length; - const toggles: Array<{ - id: ClinicalNotesTabId | "table"; - label: string; - icon: typeof ShieldCheck; - count?: number; - popout?: boolean; - }> = [ - ...tabs.map((tab) => ({ ...tab, popout: false })), - ...(tableEvidenceCount > 0 && onOpenTables - ? [{ id: "table" as const, label: "Table", icon: Table2, count: tableEvidenceCount, popout: true }] - : []), - ]; + const warningRows = clinicalNotesRowsForTab(detailSections, "safety"); + const warningCount = warningRows.filter((row) => row.tone === "warn").length || warningRows.length; if (!tabs.length || rows.length === 0) { return ( @@ -1595,148 +1895,142 @@ function ClinicalNotesChecklistPanel({ ); } - const ActiveIcon = clinicalNotesTabMeta[activeTab].icon; - const showToggleBar = toggles.length > 1; + const activeMeta = clinicalNotesTabMeta[activeTab]; return (
- {showToggleBar ? ( -
-
- {toggles.map((tab) => { - const Icon = tab.icon; - const selected = !tab.popout && tab.id === activeTab; - return ( - - ); - })} -
+ {tab.count} + + + ); + })}
- ) : null} + -
- -
-

- {activeTab === "safety" ? "Safety checklist" : "Monitoring checklist"} -

-

- {activeTab === "safety" - ? warningCount > 0 - ? `${warningCount} caution ${warningCount === 1 ? "item" : "items"} prioritised from the answer.` - : "Key actions to start and continue safely." - : "Monitoring and medication follow-up items."} -

-
+
+

+ {activeMeta.label} ({rows.length}) +

+ {tableEvidenceCount > 0 && onOpenTables ? ( + + ) : null}
-
+
{rows.map((row) => { - const expanded = row.id === activeRow?.id; const hasDistinctDetail = clinicalNoteHasDistinctDetail(row); - const RowIcon = row.tone === "warn" ? AlertCircle : CheckCircle2; + const RowIcon = row.tone === "warn" ? AlertCircle : activeTab === "actions" ? Activity : CheckCircle2; return ( -
- - {expanded && hasDistinctDetail ? : null} + +
); })}
+ {warningCount > 0 && activeTab !== "safety" ? ( + + ) : null} +
{bestSource ? ( Source ) : ( - + Source @@ -1744,7 +2038,7 @@ function ClinicalNotesChecklistPanel({ + +
+
); } @@ -3417,7 +3899,6 @@ function MobileEvidenceTabPanel({ query, visualEvidence, answerEvidenceMapRows, - pdfSources, copiedQuotes, onCopyQuotes, onFollowUpQuote, @@ -3428,12 +3909,15 @@ function MobileEvidenceTabPanel({ query: string; visualEvidence: VisualEvidenceCard[]; answerEvidenceMapRows: AnswerEvidenceMapRow[]; - pdfSources: RenderModelPdfSource[]; 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 ? ( @@ -3462,16 +3946,6 @@ function MobileEvidenceTabPanel({ ); } - if (tab === "Sources") { - return ( - - ); - } - if (tab === "Images") { return visualEvidence.length ? ( @@ -3482,43 +3956,17 @@ function MobileEvidenceTabPanel({ if (tab === "Quotes") { return ( - - ); - } - - if (tab === "PDFs") { - return pdfSources.length ? ( -
- {pdfSources.map((source, index) => ( - - - - {cleanDisplayTitle(source.title)} - - - {index === 0 ? "Main source" : "Supporting source"} · page {source.page_number ?? "n/a"} - - - - - ))} -
- ) : ( - + ); } - return ; + return ; } function UnifiedEvidenceDrawerContent({ @@ -3547,7 +3995,6 @@ function UnifiedEvidenceDrawerContent({ onScopeDocument: (documentId: string) => void; }) { const order = evidenceTabOrder(answer, renderModel); - const pdfSources = uniquePdfSourcesForRenderModel(renderModel).slice(0, 6); return (
@@ -3569,6 +4016,15 @@ function UnifiedEvidenceDrawerContent({
{order.map((section) => { + if (section === "Claims") { + return ( +
+

Claims

+ +
+ ); + } + if (section === "Tables") { return (
@@ -3604,19 +4060,6 @@ function UnifiedEvidenceDrawerContent({ ); } - if (section === "Sources") { - return ( -
-

Sources

- -
- ); - } - if (section === "Images") { return (
@@ -3647,41 +4090,10 @@ function UnifiedEvidenceDrawerContent({ ); } - if (section === "PDFs") { - return ( -
-

PDFs used

- {pdfSources.length ? ( -
- {pdfSources.map((source, index) => ( - - - - {cleanDisplayTitle(source.title)} - - - {index === 0 ? "Main source" : "Supporting source"} · page {source.page_number ?? "n/a"} - - - - - ))} -
- ) : ( - - )} -
- ); - } - return (
-

Evidence map

- +

Gaps

+
); })} @@ -3818,18 +4230,62 @@ function StagedAnswerResultSurface({ const [clinicalNotesOpen, setClinicalNotesOpen] = useState(false); const [evidenceOpen, setEvidenceOpen] = useState(false); const [evidenceInitialTab, setEvidenceInitialTab] = useState(null); + const [activeReviewPanel, setActiveReviewPanel] = useState<"clinical" | "evidence" | null>(null); const [copiedQuotes, setCopiedQuotes] = useState(false); + const clinicalNotesTriggerRef = useRef(null); + const evidenceTriggerRef = useRef(null); + const useReviewSheet = useMobilePreviewSheet(); const copyQuotesTimerRef = useRef(null); useEffect(() => { return () => { if (copyQuotesTimerRef.current !== null) window.clearTimeout(copyQuotesTimerRef.current); }; }, []); - const openTableEvidence = useCallback(() => { + function openClinicalNotes() { + setEvidenceOpen(false); + setEvidenceInitialTab(null); + if (useReviewSheet) { + setActiveReviewPanel(null); + setClinicalNotesOpen(true); + return; + } + setClinicalNotesOpen(false); + setActiveReviewPanel("clinical"); + } + function restoreFocusToTrigger(ref: RefObject) { + window.requestAnimationFrame(() => { + if (ref.current?.isConnected) ref.current.focus({ preventScroll: true }); + }); + } + function closeClinicalNotesReview() { + setClinicalNotesOpen(false); + restoreFocusToTrigger(clinicalNotesTriggerRef); + } + function openEvidence(initialTab: EvidenceTabName | null = null) { + setClinicalNotesOpen(false); + setEvidenceInitialTab(initialTab); + if (useReviewSheet) { + setActiveReviewPanel(null); + setEvidenceOpen(true); + return; + } + setEvidenceOpen(false); + setActiveReviewPanel("evidence"); + } + function closeEvidenceReview() { + setEvidenceOpen(false); + setEvidenceInitialTab(null); + restoreFocusToTrigger(evidenceTriggerRef); + } + function closeDesktopReviewPanel() { + const triggerRef = activeReviewPanel === "clinical" ? clinicalNotesTriggerRef : evidenceTriggerRef; + setActiveReviewPanel(null); + restoreFocusToTrigger(triggerRef); + } + function openTableEvidence() { setClinicalNotesOpen(false); - setEvidenceInitialTab("Tables"); - setEvidenceOpen(true); - }, [setClinicalNotesOpen, setEvidenceInitialTab, setEvidenceOpen]); + openEvidence("Tables"); + } const copyQuotes = useCallback(async () => { const quoteText = formatQuoteCardsForClipboard(renderModel.quoteCards); if (!quoteText) return; @@ -3842,6 +4298,14 @@ function StagedAnswerResultSurface({ setCopiedQuotes(false); } }, [renderModel.quoteCards]); + const priority = answerSupportPriority(answer, safeAnswerSections, centralTable, safetyFindings, { + grounded: answerGrounded, + weakEvidence, + }); + const inlineEvidenceSummary = compactEvidenceSummary(answer, sources, sourceSummary, renderModel); + const evidenceTrustLabel = inlineEvidenceSummary.split(" · ")[0] || "Review support"; + const showInlineSupportCard = Boolean(priority || showClinicalNotes || showEvidenceDrawer); + const showLayoutAside = Boolean(activeReviewPanel || centralTable); return (
@@ -3853,8 +4317,8 @@ function StagedAnswerResultSurface({ data-desktop-table-aside={centralTable ? "true" : "false"} className={cn( "space-y-3", - centralTable && - "lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.78fr)] lg:items-start lg:gap-4 lg:space-y-0", + showLayoutAside && + "lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(21rem,0.72fr)] lg:items-start lg:gap-5 lg:space-y-0", )} >
@@ -3871,10 +4335,95 @@ function StagedAnswerResultSurface({ onCopy={onCopyAnswer} /> - + {showInlineSupportCard ? ( + openEvidence(null)} + /> + ) : null} + + {centralTable && activeReviewPanel ? : null}
- {centralTable ? ( + {activeReviewPanel ? ( + + ) : centralTable ? (
@@ -3882,35 +4431,23 @@ function StagedAnswerResultSurface({
{showClinicalNotes ? ( - { - if (open) setEvidenceOpen(false); - setClinicalNotesOpen(open); - }} - sheetHeaderLeading={ + onClose={closeClinicalNotesReview} + title="Clinical notes" + description="Source-backed points from this answer." + closeLabel="Close clinical notes" + headerLeading={ } - sheetTitleAccessory={ + titleAccessory={ {clinicalNoteDisplayCount} } - sheetDescriptionContent={ - - - Source-backed - - } - sheetHeaderActions={ + headerActions={ bestSource ? ( ) : null } - sheetDescription={null} - sheetHeaderClassName="gap-2 p-2.5 sm:p-3" - sheetTitleClassName="text-[15px] leading-5" - sheetCloseButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - sheetChildrenClassName="flex min-h-0 flex-1 flex-col" - sheetContentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" - sheetContentStyle={{ height: "80dvh" }} - sheetBodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + headerClassName="gap-2 p-2.5 sm:p-3" + titleClassName="text-[15px] leading-5" + closeButtonClassName="inline-flex h-8 w-8 items-center justify-center rounded-full text-[color:var(--text-muted)] transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" + contentStyle={{ height: "80dvh" }} + bodyClassName="flex flex-col bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={clinicalNotesTriggerRef} + portal > - + ) : null} {showEvidenceDrawer ? ( - { - if (open) setClinicalNotesOpen(false); - setEvidenceOpen(open); - if (!open) setEvidenceInitialTab(null); - }} + onClose={closeEvidenceReview} + title="Evidence" + description="Review by evidence type." + titleAccessory={ + {evidenceTrustLabel} + } + closeLabel="Close evidence" + headerLeading={ + + + + } + contentClassName="max-h-[92dvh] translate-y-0 bg-[color:var(--surface-raised)] motion-safe:animate-none sm:h-auto sm:max-h-[88dvh] sm:max-w-lg" + contentStyle={{ height: "80dvh" }} + bodyClassName="bg-[color:var(--surface-raised)] px-3 pb-0 pt-2 sm:p-3" + returnFocusRef={evidenceTriggerRef} + portal > -
- -
-
- {renderModelAllows(renderModel, "sourceStatus") ? ( - <> - - - - ) : null} - {renderModelAllows(renderModel, "reviewSources") ? ( - - ) : null} - {renderModelAllows(renderModel, "warnings") ? ( - <> - - - - - ) : null} - {renderModelAllows(renderModel, "diagnostics") ? : null} - -
-
+ + ) : null} @@ -4141,11 +4626,310 @@ const tagQualityTone: Record = { overused: toneNeutral, }; +const labelTierTone: Record = { + primary: toneSuccess, + secondary: toneNeutral, + ranking: toneInfo, +}; + +const documentLabelTypeOptions: Array<{ value: DocumentLabelType; label: string }> = [ + { value: "site", label: "Site" }, + { value: "topic", label: "Topic" }, + { value: "document_type", label: "Document type" }, + { value: "medication", label: "Medication" }, + { value: "risk", label: "Risk" }, + { value: "setting", label: "Setting" }, + { value: "workflow", label: "Workflow" }, + { value: "population", label: "Population" }, + { value: "service", label: "Service" }, + { value: "clinical_action", label: "Clinical action" }, + { value: "care_phase", label: "Care phase" }, + { value: "document_intent", label: "Document intent" }, + { value: "content_feature", label: "Content feature" }, + { value: "custom", label: "Manual" }, +]; + function tagQualityLabel(kind: SmartDocumentTagQualityIssueKind) { if (kind === "low_confidence") return "low confidence"; return kind; } +function normalizedLabelReviewRow(label: DocumentLabel) { + const normalized = normalizeDocumentLabelForStorage(label); + const fallbackLabelType = documentLabelTypeOptions.some((option) => option.value === label.label_type) + ? label.label_type + : "custom"; + const labelType = normalized?.label_type ?? fallbackLabelType; + const labelText = normalized?.label ?? label.label?.trim() ?? ""; + const tier: SmartDocumentTagTier = normalized + ? documentLabelTier(normalized.label, normalized.label_type) + : "secondary"; + const reviewStatus = documentLabelReviewStatus(label); + return { + id: label.id, + label: labelText, + displayLabel: labelText ? formatDocumentLabelDisplay(labelText, labelType) : "Unreviewed label", + labelType, + tier, + reviewStatus, + source: label.source, + confidence: normalized?.confidence ?? label.confidence ?? 0, + }; +} + +function labelTypeDisplay(value: DocumentLabelType) { + return documentLabelTypeOptions.find((option) => option.value === value)?.label ?? value.replaceAll("_", " "); +} + +type LabelReviewMutationBody = + { labelId: string; action: "approve" | "hide" | "restore" } | { label: string; label_type: DocumentLabelType }; + +function DocumentLabelReviewPanel({ + documents, + canManage, + onMutateLabel, +}: { + documents: ClinicalDocument[]; + canManage: boolean; + onMutateLabel: (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => Promise; +}) { + const [busyAction, setBusyAction] = useState(null); + const [overrideDrafts, setOverrideDrafts] = useState>( + {}, + ); + + const items = useMemo(() => { + return documents + .map((document) => { + const rows = (document.labels ?? []) + .map((label) => normalizedLabelReviewRow(label)) + .filter((row): row is NonNullable> => Boolean(row)); + const visible = rows.filter((row) => row.reviewStatus !== "hidden" && row.tier !== "ranking"); + const ranking = rows.filter((row) => row.reviewStatus !== "hidden" && row.tier === "ranking"); + const hidden = rows.filter((row) => row.reviewStatus === "hidden"); + const needsReview = rows.some((row) => row.reviewStatus === "new" && row.source === "generated"); + return { document, rows, visible, ranking, hidden, needsReview }; + }) + .filter((item) => item.rows.length) + .sort((a, b) => Number(b.needsReview) - Number(a.needsReview) || b.ranking.length - a.ranking.length) + .slice(0, 8); + }, [documents]); + + if (!items.length) return null; + + async function mutate(documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody, actionId: string) { + setBusyAction(actionId); + try { + return await onMutateLabel(documentId, method, body); + } finally { + setBusyAction(null); + } + } + + function draftFor(documentId: string) { + return overrideDrafts[documentId] ?? { label: "", labelType: "topic" as DocumentLabelType }; + } + + function setDraft(documentId: string, next: { label: string; labelType: DocumentLabelType }) { + setOverrideDrafts((current) => ({ ...current, [documentId]: next })); + } + + return ( +
+ + + + + + + Label review + + Visible labels, ranking labels, hidden labels, confidence, and manual overrides + + + + + +
+ {items.map((item) => { + const draft = draftFor(item.document.id); + return ( +
+
+
+ + {documentDisplayTitle(item.document)} + +

+ {item.visible.length} visible · {item.ranking.length} ranking · {item.hidden.length} hidden +

+
+ {item.needsReview ? ( + Needs review + ) : ( + Reviewed + )} +
+ + {( + [ + { title: "Visible", rows: item.visible }, + { title: "Ranking", rows: item.ranking }, + { title: "Hidden", rows: item.hidden }, + ] satisfies Array<{ title: string; rows: typeof item.rows }> + ).map(({ title, rows: labelRows }) => { + if (!labelRows.length) return null; + return ( +
+

+ {title} +

+
+ {labelRows.slice(0, 8).map((label) => ( +
+
+
+ + {label.displayLabel} + + + {label.tier} + + + {labelTypeDisplay(label.labelType)} + +
+

+ {label.source} · {Math.round(label.confidence * 100)}% · {label.reviewStatus} +

+
+
+ {label.reviewStatus === "hidden" ? ( + + ) : ( + <> + + + + )} +
+
+ ))} +
+
+ ); + })} + +
{ + event.preventDefault(); + const trimmed = draft.label.trim(); + if (!trimmed) return; + void mutate( + item.document.id, + "POST", + { label: trimmed, label_type: draft.labelType }, + `override:${item.document.id}`, + ).then((ok) => { + if (ok) setDraft(item.document.id, { label: "", labelType: draft.labelType }); + }); + }} + > + setDraft(item.document.id, { ...draft, label: event.target.value })} + disabled={!canManage || busyAction !== null} + placeholder="Manual override label" + className={fieldControlPlain} + /> + + +
+
+ ); + })} +
+
+ ); +} + function DocumentTagQualityPanel({ documents }: { documents: ClinicalDocument[] }) { const issues = useMemo(() => reviewDocumentTagQuality(documents), [documents]); const counts = issues.reduce>( @@ -4308,6 +5092,7 @@ function DocumentDrawer({ bulkActionBusy, canManageDocuments, onTagSearch, + onMutateLabel, }: { documents: ClinicalDocument[]; pagination: DocumentPagination | null; @@ -4326,6 +5111,7 @@ function DocumentDrawer({ bulkActionBusy: boolean; canManageDocuments: boolean; onTagSearch: (tag: SmartDocumentTag) => void; + onMutateLabel: (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => Promise; }) { const [filter, setFilter] = useState(""); const [selectedType, setSelectedType] = useState("all"); @@ -4592,6 +5378,9 @@ function DocumentDrawer({ Showing {documents.length} of {pagination.total} documents. Load more to manage older files.

) : null} + {isAdminMode ? ( + + ) : null} {isAdminMode ? : null} {isAdminMode ? : null} {isAdminMode && selectedDocumentIds.length ? ( @@ -6212,6 +7001,93 @@ export function ClinicalDashboard({ setAnswer((current) => applyRenamedDocumentToAnswer(current, updatedDocument)); }, []); + const handleDocumentLabelsUpdated = useCallback((documentId: string, labels: DocumentLabel[]) => { + setDocuments((current) => + current.map((document) => (document.id === documentId ? { ...document, labels } : document)), + ); + setDocumentMatches((current) => + current.map((document) => (document.document_id === documentId ? { ...document, labels } : document)), + ); + setSources((current) => + current.map((source) => (source.document_id === documentId ? { ...source, document_labels: labels } : source)), + ); + }, []); + + const handleDocumentLabelPatched = useCallback((documentId: string, label: DocumentLabel) => { + function mergeLabel(labels: DocumentLabel[] | null | undefined) { + const current = labels ?? []; + let replaced = false; + const next = current.map((item) => { + if (item.id !== label.id) return item; + replaced = true; + return label; + }); + return replaced ? next : [label, ...next]; + } + + setDocuments((current) => + current.map((document) => + document.id === documentId ? { ...document, labels: mergeLabel(document.labels) } : document, + ), + ); + setDocumentMatches((current) => + current.map((document) => + document.document_id === documentId ? { ...document, labels: mergeLabel(document.labels) } : document, + ), + ); + setSources((current) => + current.map((source) => + source.document_id === documentId ? { ...source, document_labels: mergeLabel(source.document_labels) } : source, + ), + ); + }, []); + + const mutateDocumentLabel = useCallback( + async (documentId: string, method: "POST" | "PATCH", body: LabelReviewMutationBody) => { + if (!canUsePrivateApis) return false; + try { + const response = await fetch(`/api/documents/${documentId}/labels`, { + method, + headers: { + "Content-Type": "application/json", + ...(clientDemoMode ? {} : authorizationHeader), + }, + body: JSON.stringify(body), + }); + const payload = await response.json().catch(() => ({})); + if (response.status === 401) { + markSessionExpired(); + return false; + } + if (!response.ok) { + setActionNotice({ + tone: "warning", + message: typeof payload?.error === "string" ? payload.error : "Label update failed.", + }); + return false; + } + if (Array.isArray(payload.labels)) { + handleDocumentLabelsUpdated(documentId, payload.labels as DocumentLabel[]); + } else if (payload.label && typeof payload.label === "object") { + handleDocumentLabelPatched(documentId, payload.label as DocumentLabel); + } + setActionNotice({ tone: "success", message: "Document label review updated." }); + return true; + } catch { + setActionNotice({ tone: "warning", message: "Label update failed." }); + return false; + } + }, + [ + authorizationHeader, + canUsePrivateApis, + clientDemoMode, + handleDocumentLabelPatched, + handleDocumentLabelsUpdated, + markSessionExpired, + ], + ); + const handleDocumentDeleted = useCallback( (result: DocumentDeleteResult) => { setDocuments((current) => current.filter((document) => document.id !== result.documentId)); @@ -7015,6 +7891,13 @@ export function ClinicalDashboard({ function openEvidenceDrawer() { closeDashboardTransientSurfaces(); + const reviewTrigger = document.getElementById("answer-evidence-drawer-mobile-trigger") as HTMLButtonElement | null; + if (reviewTrigger) { + reviewTrigger.scrollIntoView({ block: "center", behavior: "smooth" }); + reviewTrigger.click(); + return; + } + const drawer = document.getElementById("answer-evidence-drawer") as HTMLDetailsElement | null; if (!drawer) { setActionNotice({ @@ -7634,6 +8517,7 @@ export function ClinicalDashboard({ query={query} loading={loading} documentCount={indexedDocumentTotal} + recentDocuments={documents} realDataReady={canRunSearch} authUnavailable={!clientDemoMode && !canUsePrivateApis} apiUnavailable={apiUnavailable} @@ -7744,6 +8628,7 @@ export function ClinicalDashboard({ bulkActionBusy={bulkActionBusy} canManageDocuments={canUsePrivateApis} onTagSearch={handleTagSearch} + onMutateLabel={mutateDocumentLabel} /> ) : null} diff --git a/src/components/DocumentTagCloud.tsx b/src/components/DocumentTagCloud.tsx index 362e337263..084a15806f 100644 --- a/src/components/DocumentTagCloud.tsx +++ b/src/components/DocumentTagCloud.tsx @@ -4,7 +4,7 @@ import { Clock3, FileText, ListChecks, ShieldAlert, Sparkles, Tag, Target, Users import { useMemo, useState } from "react"; import { buildSmartDocumentTags, - groupSmartDocumentTags, + groupSmartDocumentTagsFromTags, type SmartDocumentTag, type SmartDocumentTagGroup, } from "@/lib/document-tags"; @@ -12,7 +12,7 @@ import type { DocumentLabel } from "@/lib/types"; import { cn } from "@/components/ui-primitives"; type DocumentTagCloudProps = { - labels?: Array> | null; + labels?: Array> | null; query?: string; limit?: number; compact?: boolean; @@ -118,14 +118,8 @@ export function DocumentTagCloud({ selectedTagKeys, grouped = false, }: DocumentTagCloudProps) { - const tags = useMemo( - () => buildSmartDocumentTags(labels, { query, includeManualGroup: true }).filter((tag) => tag.tier !== "ranking"), - [labels, query], - ); - const groupedTags = useMemo( - () => groupSmartDocumentTags(labels, { query, includeManualGroup: true }), - [labels, query], - ); + const tags = useMemo(() => buildSmartDocumentTags(labels, { query, includeManualGroup: true }), [labels, query]); + const groupedTags = useMemo(() => groupSmartDocumentTagsFromTags(tags), [tags]); const selected = useMemo(() => new Set(selectedTagKeys ?? []), [selectedTagKeys]); const [expanded, setExpanded] = useState(false); if (tags.length === 0) return null; diff --git a/src/components/clinical-dashboard-client.tsx b/src/components/clinical-dashboard-client.tsx new file mode 100644 index 0000000000..d9a3bee829 --- /dev/null +++ b/src/components/clinical-dashboard-client.tsx @@ -0,0 +1,19 @@ +"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/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx index fb7c6eb68c..1266fd2238 100644 --- a/src/components/clinical-dashboard/document-search-results.tsx +++ b/src/components/clinical-dashboard/document-search-results.tsx @@ -1,9 +1,11 @@ "use client"; +import Link from "next/link"; import { useMemo, useState } from "react"; import { AlertCircle, BookOpen, + CheckCircle2, ChevronDown, Clock3, ExternalLink, @@ -24,7 +26,6 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges"; import { SafeBoldText } from "@/components/SafeBoldText"; -import { ModeHomeTemplate } from "@/components/mode-home-template"; import { DocumentActionButton, DocumentActionLink, @@ -43,8 +44,8 @@ import { textMuted, } from "@/components/ui-primitives"; import { - buildSmartDocumentTagFacets, - filterDocumentsBySmartTagFacets, + buildSmartDocumentTagFacetIndex, + filterDocumentsBySmartTagFacetIndex, smartDocumentFacetGroups, type SmartDocumentTag, type SmartDocumentTagFacet, @@ -52,7 +53,7 @@ import { } from "@/lib/document-tags"; import type { ServiceSearchMatch } from "@/lib/services"; import type { FormSearchMatch } from "@/lib/forms"; -import type { DocumentMatch, SearchResult } from "@/lib/types"; +import type { ClinicalDocument, DocumentMatch, SearchResult } from "@/lib/types"; import { documentRelevancePercent } from "./relevance-score"; type SearchFacet = { value: string; count: number }; @@ -214,7 +215,7 @@ function documentPageLabel(document: DocumentMatch) { const pages = document.bestPages.filter((page) => Number.isFinite(page)); if (pages.length === 0) return "Page n/a"; if (pages.length === 1) return `p.${pages[0]}`; - return `p.${pages.slice(0, 2).join("-")}`; + return `p.${pages[0]} +${pages.length - 1}`; } function resultTypeTabs(matches: DocumentMatch[]) { @@ -239,38 +240,6 @@ function filterMatchesByResultType(matches: DocumentMatch[], filter: ResultTypeF return matches; } -function compactEvidenceBadges(document: DocumentMatch): Array<{ - label: string; - icon: LucideIcon; - variant?: "neutral" | "relevant"; -}> { - const extension = document.file_name.toLowerCase().endsWith(".pdf") - ? "PDF" - : document.file_name.split(".").pop()?.toUpperCase() || "DOC"; - const badges: Array<{ label: string; icon: LucideIcon; variant?: "neutral" | "relevant" }> = [ - { label: extension, icon: FileText }, - { label: documentPageLabel(document), icon: BookOpen }, - ]; - - if (document.tableCount > 0) { - badges.push({ - label: `${document.tableCount} table${document.tableCount === 1 ? "" : "s"}`, - icon: ListChecks, - variant: "relevant", - }); - } - - if (document.imageCount > 0) { - badges.push({ - label: `${document.imageCount} image${document.imageCount === 1 ? "" : "s"}`, - icon: FileImage, - variant: "relevant", - }); - } - - return badges; -} - function compactMatchReason(document: DocumentMatch) { const relevance = document.relevance; if (relevance?.verdict === "direct") { @@ -325,51 +294,102 @@ function documentOpenHref(document: DocumentMatch) { return `/documents/${document.document_id}?${params.toString()}`; } -function WhyThisResultDisclosure({ document }: { document: DocumentMatch }) { - const relevanceDisplay = relevanceTone(document); - const matchedTerms = document.relevance?.matchedTerms?.slice(0, 5) ?? []; - const missingTerms = document.relevance?.missingTerms?.slice(0, 4) ?? []; - const evidenceTypes = [ - document.tableCount > 0 ? `${document.tableCount} table${document.tableCount === 1 ? "" : "s"}` : "", - document.imageCount > 0 ? `${document.imageCount} image${document.imageCount === 1 ? "" : "s"}` : "", - document.file_name.toLowerCase().endsWith(".pdf") ? "PDF source" : "", - ].filter(Boolean); +function documentMetadataRecord(document: Pick) { + return document.metadata && typeof document.metadata === "object" && !Array.isArray(document.metadata) + ? (document.metadata as Record) + : {}; +} + +function documentStatusText(document: ClinicalDocument) { + const metadata = documentMetadataRecord(document); + const sourceStatus = String(metadata.document_status ?? ""); + if (sourceStatus === "review_due") return "Review due"; + if (sourceStatus === "outdated") return "Outdated"; + if (document.status === "indexed") return "Indexed"; + if (document.status === "processing") return "Indexing"; + if (document.status === "failed") return "Failed"; + return "Queued"; +} + +function formatDocumentDate(value?: string | null) { + if (!value) return "Recently updated"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "Recently updated"; + return new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short" }).format(date); +} + +function topDocumentFacets(documents: ClinicalDocument[]) { + return buildSmartDocumentTagFacetIndex(documents, { limitPerGroup: 4 }) + .groups.flatMap((group) => group.facets.map((facet) => ({ ...facet, group: facet.group }))) + .slice(0, 8); +} + +function DocumentHomeLane({ + title, + count, + icon: Icon, + tone, +}: { + title: string; + count: number | string; + icon: LucideIcon; + tone: "success" | "warning" | "info"; +}) { + 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 ( -
- - Why this result? - -
-
- {sourceSupportLabel(document)} - {relevanceDisplay.detail} -
-

{compactMatchReason(document)}

- {matchedTerms.length ?

Matched terms: {matchedTerms.join(", ")}

: null} - {missingTerms.length ?

Not directly found: {missingTerms.join(", ")}

: null} - {evidenceTypes.length ?

Evidence available: {evidenceTypes.join(", ")}

: null} -
-
+
+ + +

{count}

+

{title}

+
+ ); +} + +function RecentDocumentLink({ document }: { document: ClinicalDocument }) { + const kind = documentFileKind(document.file_name, "PDF"); + return ( + + + + + {documentDisplayTitle(document)} + + + {documentStatusText(document)} - {document.page_count} page{document.page_count === 1 ? "" : "s"} -{" "} + {formatDocumentDate(document.updated_at)} + + +
+
+ + Label filters + + {activeLabelFilterCount ? `${activeLabelFilterCount} active` : "Medication, site, action, intent"} + + + +
+ {renderLabelScopeFilterGrid(false)} + +
+

Document scope

diff --git a/src/components/document-search-live-opener.tsx b/src/components/document-search-live-opener.tsx new file mode 100644 index 0000000000..79e2f468b5 --- /dev/null +++ b/src/components/document-search-live-opener.tsx @@ -0,0 +1,234 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { AlertCircle, ArrowLeft, ExternalLink, FileText, Loader2, Search } 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: "error"; message: string; liveHref?: 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"; + +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()}`; +} + +export function DocumentSearchLiveOpener() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { authorizationHeader } = useAuthSession(); + const query = searchParams.get("q")?.trim() || defaultQuery; + const documentHint = searchParams.get("document")?.trim() || "clozapine"; + 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() { + 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: "error", + message: "No indexed documents are available to open in the live viewer.", + }); + 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: "error", + message: error instanceof Error ? error.message : "The live document could not be opened.", + }); + } + } + + void openLiveDocument(); + return () => controller.abort(); + }, [authorizationHeader, lookupTerm, query, router]); + + return ( +
+
+ +
+
+ ); +} diff --git a/src/components/document-search-mockups.tsx b/src/components/document-search-mockups.tsx new file mode 100644 index 0000000000..32e33e1b73 --- /dev/null +++ b/src/components/document-search-mockups.tsx @@ -0,0 +1,781 @@ +import Image from "next/image"; +import Link from "next/link"; +import { + AlertCircle, + ArrowRight, + BookOpen, + CheckCircle2, + ChevronDown, + Clock3, + ExternalLink, + FileImage, + FileText, + Filter, + FolderOpen, + ListChecks, + Search, + ShieldAlert, + SlidersHorizontal, + Sparkles, + Table2, + Tag, + Target, + type LucideIcon, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "@/components/ui-primitives"; + +export type DocumentSearchMockupVariant = "command" | "evidence-lens" | "triage-board"; + +type DocumentFixture = { + slug: string; + title: string; + meta: string; + summary: string; + relevance: string; + metadata: string; + caution?: string; + page: string; + icon: LucideIcon; + tags: string[]; + active?: boolean; +}; + +type VariantCopy = { + eyebrow: string; + title: string; + body: string; + asset: { + src: string; + alt: string; + }; + priorities: string[]; +}; + +const focusRing = + "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]"; + +const documents: DocumentFixture[] = [ + { + slug: "clozapine-monitoring", + title: "Clozapine physical health monitoring protocol", + meta: "Current source - p.12 - table evidence", + summary: "Monitoring schedule, escalation thresholds, and shared-care checks are visible before opening the PDF.", + relevance: "High relevance", + metadata: "Protocol", + caution: "Review 2026", + page: "p.12", + icon: Table2, + tags: ["Medication", "Monitoring", "Shared care"], + active: true, + }, + { + slug: "acute-agitation-pathway", + title: "Acute agitation clinical pathway", + meta: "Local guideline - p.4 - image and flowchart", + summary: + "The result row separates pathway evidence from nearby medication references and keeps actions thumb-ready.", + relevance: "Relevant", + metadata: "Guideline", + page: "p.4", + icon: FileImage, + tags: ["Risk", "Escalation", "ED"], + }, + { + slug: "mental-health-act-forms", + title: "Mental Health Act forms quick reference", + meta: "Indexed source - p.2 - form checklist", + summary: "Fast access to document type, responsible service, and the exact page most likely to answer the search.", + relevance: "Exact title", + metadata: "Quick reference", + page: "p.2", + icon: ListChecks, + tags: ["Forms", "Workflow", "Legal"], + }, +]; + +function highlightedDocumentHref(document: DocumentFixture) { + const params = new URLSearchParams({ + mode: "documents", + document: document.slug, + q: document.active ? "clozapine monitoring table" : document.title, + page: document.page.replace("p.", ""), + chunk: document.active ? "monitoring-table" : "best-match", + }); + return `/mockups/document-search/source?${params.toString()}`; +} + +const facets = [ + { label: "Medication", count: 42, icon: Target }, + { label: "Risk", count: 31, icon: ShieldAlert }, + { label: "Forms", count: 18, icon: ListChecks }, + { label: "Tables", count: 64, icon: Table2 }, + { label: "Review due", count: 9, icon: AlertCircle }, +]; + +const variantCopy: Record = { + command: { + eyebrow: "Production candidate", + title: "Document search command center", + body: "A compact scanning layout for clinicians who know the term they need and want the right source, page, and action without opening every PDF.", + asset: { + src: "/mockups/document-search/source-stack.png", + alt: "Synthetic layered document stack with highlighted abstract source regions.", + }, + priorities: ["Fast scan", "Sort and filter clarity", "Active source preview"], + }, + "evidence-lens": { + eyebrow: "Evidence lens", + title: "Search result with source proof in view", + body: "A split workbench that treats the top result as an evidence object: page, table, image, and match reasoning stay visible together.", + asset: { + src: "/mockups/document-search/evidence-preview.png", + alt: "Synthetic source page connected to abstract table, image, and warning evidence panels.", + }, + priorities: ["Preview first", "Explain ranking", "Open exact evidence"], + }, + "triage-board": { + eyebrow: "Discovery board", + title: "Document library triage before the query", + body: "A discovery-first version for browsing recent work, source health, smart facets, and status lanes before running a focused search.", + asset: { + src: "/mockups/document-search/triage-map.png", + alt: "Synthetic document triage board with abstract grouped source cards and status lanes.", + }, + priorities: ["Recent work", "Source health", "Facet discovery"], + }, +}; + +function IconTile({ icon: Icon, tone = "accent" }: { icon: LucideIcon; tone?: "accent" | "info" | "neutral" }) { + const toneClass = + tone === "info" + ? "border-[color:var(--info-border)] bg-[color:var(--info-soft)] text-[color:var(--info)]" + : tone === "neutral" + ? "border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]" + : "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]"; + + return ( + + + ); +} + +function Pill({ + children, + tone = "neutral", + icon: Icon, +}: { + children: ReactNode; + tone?: "neutral" | "accent" | "info" | "success" | "warning" | "danger"; + icon?: LucideIcon; +}) { + 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)]" + : tone === "danger" + ? "border-[color:var(--danger-border)] bg-[color:var(--danger-soft)] text-[color:var(--danger)]" + : "border-[color:var(--border-lux)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)]"; + + return ( + + {Icon ? + ); +} + +function Button({ + children, + primary = false, + icon: Icon, +}: { + children: ReactNode; + primary?: boolean; + icon?: LucideIcon; +}) { + return ( + + ); +} + +function ActionLink({ + children, + href, + primary = false, + icon: Icon, +}: { + children: ReactNode; + href: string; + primary?: boolean; + icon?: LucideIcon; +}) { + return ( + + {Icon ?
+ ); +} + +function Metric({ label, value, icon: Icon }: { label: string; value: string; icon: LucideIcon }) { + return ( +
+
+ ); +} + +function MobileShell({ title, children }: { title: string; children: ReactNode }) { + return ( + +
+
+
+

+ Documents +

+

{title}

+
+
{children}
+
+
+
+ ); +} + +function MobileCommandPreview() { + return ( + + +
+ {["Best", "Tables", "Current", "Local"].map((item, index) => ( + + {item} + + ))} +
+ {documents.slice(0, 2).map((document) => ( + + ))} +
+ ); +} + +function EvidenceLensMockup({ copy }: { copy: VariantCopy }) { + return ( +
+ +
+ +
+ +
+ + + +
+
+ +
+
+ +
+ ); +} + +function EvidenceTile({ + icon: Icon, + title, + body, + tone, +}: { + icon: LucideIcon; + title: string; + body: string; + tone: "success" | "info" | "warning"; +}) { + 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 ( +
+ + +

{title}

+

{body}

+
+ ); +} + +function ReasonRow({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function MobileEvidencePreview() { + return ( + + +
+

+ Preview +

+

+ p.12 table, source text, and review note are stacked before the PDF opens. +

+
+
+ + + +
+
+ ); +} + +function TriageBoardMockup({ copy }: { copy: VariantCopy }) { + return ( +
+ +
+
+ +
+ + + +
+ +
+ +
+
+ +
+ ); +} + +function FacetButton({ facet }: { facet: (typeof facets)[number] }) { + const Icon = facet.icon; + return ( + + ); +} + +function BoardLane({ + title, + count, + icon, + tone, +}: { + title: string; + count: string; + icon: LucideIcon; + tone: "success" | "warning" | "info"; +}) { + return ( +
+
+ + {title} + +
+

{count}

+

indexed documents

+
+ ); +} + +function MobileTriagePreview() { + return ( + + +
+ + +
+
+ {facets.slice(0, 4).map((facet) => ( + + {facet.label} + + ))} +
+ +
+ ); +} + +export function DocumentSearchMockupPage({ variant }: { variant: DocumentSearchMockupVariant }) { + const copy = variantCopy[variant]; + return ( +
+
+ + {variant === "command" ? : null} + {variant === "evidence-lens" ? : null} + {variant === "triage-board" ? : null} +
+
+ ); +} diff --git a/src/components/document-viewer-client.tsx b/src/components/document-viewer-client.tsx new file mode 100644 index 0000000000..bfbbb86c2c --- /dev/null +++ b/src/components/document-viewer-client.tsx @@ -0,0 +1,17 @@ +"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/forms-search-results-page.tsx b/src/components/forms/forms-search-results-page.tsx index 91827b494f..3fd42cbd59 100644 --- a/src/components/forms/forms-search-results-page.tsx +++ b/src/components/forms/forms-search-results-page.tsx @@ -104,7 +104,7 @@ function FormsSidebar() { key={label} type="button" className={cn( - "grid min-h-12 grid-cols-[2rem_1fr] items-center gap-3 rounded-lg px-3 text-left text-sm font-bold transition", + "grid min-h-12 grid-cols-[2rem_1fr] items-center gap-3 rounded-lg px-3 text-left text-sm font-bold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white/45", active ? "bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] shadow-[var(--shadow-tight)]" : "text-white/90 hover:bg-white/8", @@ -117,7 +117,10 @@ function FormsSidebar() {
- @@ -160,7 +163,7 @@ function DesktopTopBar({ onSearch }: { onSearch: (query: string) => void }) { @@ -172,14 +175,19 @@ function DesktopTopBar({ onSearch }: { onSearch: (query: string) => void }) {
- - @@ -255,7 +267,7 @@ function SearchSummary({ @@ -475,7 +490,7 @@ function NextSteps() {