diff --git a/.agents/skills/catalog.json b/.agents/skills/catalog.json index 21b4ce308..06f362d22 100644 --- a/.agents/skills/catalog.json +++ b/.agents/skills/catalog.json @@ -3,19 +3,59 @@ "categories": [ { "name": "Everyday", - "skills": ["skills", "plan", "run", "test", "fix", "review", "task", "handover", "health", "audit", "export"] + "skills": [ + "skills", + "plan", + "run", + "test", + "fix", + "review", + "task", + "handover", + "health", + "audit", + "export", + "prompt-perfector", + "dead-code-finder", + "performance-profiling", + "test-coverage-review" + ] }, { "name": "Clinical and app", - "skills": ["clinical", "ui", "rag", "sources", "ingest", "reindex", "documents"] + "skills": [ + "clinical", + "ui", + "rag", + "sources", + "ingest", + "reindex", + "documents" + ] }, { "name": "Database, API, and security", - "skills": ["migrate", "drift", "access", "security", "privacy", "api", "data"] + "skills": [ + "migrate", + "drift", + "access", + "security", + "privacy", + "api", + "data" + ] }, { "name": "Operations and release", - "skills": ["release", "deploy", "operations", "incident", "recovery", "performance", "dependencies"] + "skills": [ + "release", + "deploy", + "operations", + "incident", + "recovery", + "performance", + "dependencies" + ] } ], "aliases": { diff --git a/.agents/skills/dead-code-finder/SKILL.md b/.agents/skills/dead-code-finder/SKILL.md new file mode 100644 index 000000000..6f641b36b --- /dev/null +++ b/.agents/skills/dead-code-finder/SKILL.md @@ -0,0 +1,7 @@ +--- +name: dead-code-finder +description: Description for dead-code-finder. Use this skill to do something. +--- +# dead-code-finder + +Detailed instructions for the dead-code-finder skill. diff --git a/.agents/skills/dead-code-finder/agents/openai.yaml b/.agents/skills/dead-code-finder/agents/openai.yaml new file mode 100644 index 000000000..b29a5c819 --- /dev/null +++ b/.agents/skills/dead-code-finder/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Dead Code Finder" + short_description: "Find dead code in the repository" + default_prompt: "Use $dead-code-finder to find dead code." diff --git a/.agents/skills/performance-profiling/SKILL.md b/.agents/skills/performance-profiling/SKILL.md new file mode 100644 index 000000000..ee151e235 --- /dev/null +++ b/.agents/skills/performance-profiling/SKILL.md @@ -0,0 +1,7 @@ +--- +name: performance-profiling +description: Description for performance-profiling. Use this skill to do something. +--- +# performance-profiling + +Detailed instructions for the performance-profiling skill. diff --git a/.agents/skills/performance-profiling/agents/openai.yaml b/.agents/skills/performance-profiling/agents/openai.yaml new file mode 100644 index 000000000..f7e780efc --- /dev/null +++ b/.agents/skills/performance-profiling/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Performance Profiling" + short_description: "Profile performance of database queries" + default_prompt: "Use $performance-profiling to profile performance." diff --git a/.agents/skills/prompt-perfector/SKILL.md b/.agents/skills/prompt-perfector/SKILL.md new file mode 100644 index 000000000..a69a09372 --- /dev/null +++ b/.agents/skills/prompt-perfector/SKILL.md @@ -0,0 +1,20 @@ +--- +name: prompt-perfector +description: Refine, structure, and optimize user prompts for LLMs while ensuring execution occurs in a isolated environment. Use when asked to polish, perfect, or evaluate prompts safely. +--- + +# Prompt Perfector + +Refines user prompts into structured, highly effective instructions and executes evaluation tasks in an isolated workspace (`Workspace: "branch"`). + +## Core Capabilities + +1. **Prompt Refinement**: Analyzes input prompts for clarity, context, constraints, output format specifications, and edge cases. +2. **Environment Isolation**: Ensures any code execution, prompt testing, or subagent tasks spawned for prompt validation run within an isolated workspace (`Workspace: "branch"` or `"share"`). + +## Workflow + +1. **Deconstruct Intent**: Identify the goal, target model, domain constraints, and missing specifications. +2. **Enhance Structure**: Apply structured formatting (System Instructions, Context, Input Schema, Output Constraints, Examples). +3. **Isolated Testing**: If prompt validation requires subagent execution or file testing, invoke subagents with `Workspace: "branch"`. +4. **Deliver Output**: Present the perfected prompt with a summary of structural enhancements and usage recommendations. diff --git a/.agents/skills/prompt-perfector/agents/openai.yaml b/.agents/skills/prompt-perfector/agents/openai.yaml new file mode 100644 index 000000000..f36a811d6 --- /dev/null +++ b/.agents/skills/prompt-perfector/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Prompt Perfector" + short_description: "Refine user prompts in isolated environments" + default_prompt: "Use $prompt-perfector to refine and structure this prompt." diff --git a/.agents/skills/test-coverage-review/SKILL.md b/.agents/skills/test-coverage-review/SKILL.md new file mode 100644 index 000000000..c884ac133 --- /dev/null +++ b/.agents/skills/test-coverage-review/SKILL.md @@ -0,0 +1,7 @@ +--- +name: test-coverage-review +description: Description for test-coverage-review. Use this skill to do something. +--- +# test-coverage-review + +Detailed instructions for the test-coverage-review skill. diff --git a/.agents/skills/test-coverage-review/agents/openai.yaml b/.agents/skills/test-coverage-review/agents/openai.yaml new file mode 100644 index 000000000..49b564b5f --- /dev/null +++ b/.agents/skills/test-coverage-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Test Coverage Review" + short_description: "Review test coverage for the repository" + default_prompt: "Use $test-coverage-review to review test coverage." diff --git a/.gitattributes b/.gitattributes index 7fc3c1ecc..71e17286c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,3 +7,7 @@ *.pdf binary *.png binary *.webp binary + +# Review records are append-only. Concurrent branches should retain both sets +# of rows instead of stopping on an add/add conflict at the shared table tail. +docs/branch-review-ledger.md merge=union diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 680b02f32..9be3e7e74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,9 @@ jobs: - name: Gate-manifest self-test run: npm run check:gate-manifest + - name: Branch review ledger integrity + run: npm run check:branch-review-ledger + - name: Codebase index coverage run: npm run docs:check-index diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index 85abccb7a..0a6f6208e 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -2,7 +2,7 @@ name: PR Policy on: pull_request_target: - branches: [main] + branches: [main, "release/**"] types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled] merge_group: diff --git a/.npmrc b/.npmrc index 4ade96e63..b6f27f135 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ engine-strict=true -allow-scripts=true diff --git a/AGENTS.md b/AGENTS.md index 8b2c2b3a9..50a3bd579 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,7 +143,7 @@ Review routing: - `branch-cleanup`: Use only when the prompt explicitly asks for branch cleanup/hygiene or branch deletion candidates. Apply `docs/branch-cleanup-guide.md` and the review ledger before inspecting branch diffs. - `pr-ci-fix`: Confirmation-required for this repo. GitHub/GitLab API calls, PR comments, CI reruns, commits, and pushes require explicit user approval and must respect the upload/handoff rules. Exception: an explicit `Run PR` sweep carries this approval (see "## Run PR shortcut"). -When a branch or PR review completes, record the reviewed branch/ref, HEAD SHA, date, scope, outcome, and checks in `docs/branch-review-ledger.md`. +When a branch or PR review completes, append the reviewed branch/ref, HEAD SHA, date, scope, outcome, and checks to `docs/branch-review-ledger.md`. The ledger is append-only: never edit or delete an existing record; append a correction or superseding record instead. Its `merge=union` attribute preserves concurrent appends, and `npm run check:branch-review-ledger` blocks conflict markers, exact duplicate records, or loss of that merge protection. @@ -207,6 +207,21 @@ action must perform one; a page that ships must be reachable. + + +# Search chrome behaviour + +The shared search chrome must adapt by page ownership, not by ad-hoc padding or route-local overlays. Before changing `MasterSearchHeader`, `GlobalSearchShell`, `ClinicalDashboard`, `DocumentViewer`, phone dock reserves, or search-composer placement, read `docs/search-chrome-behaviour.md`. + +- **One owner.** A page either uses the shell/dashboard composer, owns an in-flow hero composer, or owns a document-viewer composer. Do not stack a second fixed search bar or a second dock-sized content pad below a page-owned composer. +- **Phone edge-to-edge contract.** Fixed phone composers are flush to the viewport bottom and paint their own safe-area/home-indicator region while visible. They must not use a non-zero `bottom` gap in edge-to-edge dock mode. +- **Hidden means zero reserve.** When phone search/header/footer chrome scroll-hides, the content-facing reserve is `0rem`; do not restore `0.75rem`, `env(safe-area-inset-bottom)`, or `var(--safe-area-bottom)` as hidden padding. Visible composer chrome may still consume safe-area inset. +- **Header/footer symmetry.** Top header and bottom composer hide/reveal from the same scroll signal where they share a scroll container. If one is hidden, page content behind that edge must be fully visible rather than covered by an opaque white/surface band. +- **Page adaptation.** Standalone mode homes keep the composer in-flow in the hero on phones; submitted/search-result views use the compact bottom dock; answer mode may use overlaid glass header behaviour with matching top reserve; document detail/source routes let `DocumentViewer` own its composer. +- **Guards.** Update the reserve helper, CSS tokens, Playwright phone-scroll coverage, and static contract tests together. Do not silence the existing reserve/overlay tests; add a narrower guard for any new page-specific exception. + + + # Supabase project safety diff --git a/data/forms-catalog.json b/data/forms-catalog.json index 0ab5137f0..1e8a212b6 100644 --- a/data/forms-catalog.json +++ b/data/forms-catalog.json @@ -29,8 +29,8 @@ "destination": "Examination place must be clear and align with any detention or transport authority.", "authorises": "Referral for examination by a psychiatrist.", "doesNotAuthorise": "Treatment, detention, transport, restraint, seclusion or force by itself.", - "before": ["2"], - "parallel": ["3A", "4A"], + "before": [], + "parallel": ["1A attachment"], "after": ["5A", "6A", "6B"], "copies": "Use approved form pathway; include confidential attachment only where required.", "documentationStem": "Assessed at [time/place]. Reasonably suspect need for involuntary treatment order because [illness features], [risk], [capacity issue] and [least restrictive reasoning]. Examination destination: [place].", diff --git a/docs/audit-handover-2026-07-14.md b/docs/audit-handover-2026-07-14.md index 42579a98a..f63d84255 100644 --- a/docs/audit-handover-2026-07-14.md +++ b/docs/audit-handover-2026-07-14.md @@ -117,19 +117,19 @@ evals, service-role tenancy regression class, upstream OCR quality labels drivin ### 4.1 Security / privacy / API -| ID | Finding | Evidence | Remediation wave | -| --- | ----------------------------------------------------------- | -------------------------------------------------------- | ---------------- | -| S1 | Authed public-doc DTOs leak `storage_path` / `content_hash` | `documents/route.ts`, `documents/[id]/route.ts` | Wave D1 | -| S2 | Auth UI trusts `getSession()`; APIs use `getUser` | `src/lib/supabase/client.tsx` | Wave D3 | -| S3 | Public-upload quarantine pool high blast radius if enabled | `upload/route.ts` + pool owner | Wave D4 | -| S4 | Auth 401 envelope `{ error }` only | `supabase/auth.ts` vs `http.ts` | Wave H1 | -| S5 | Many hand-rolled `{ error }` omit `code`/`message` | upload, doc 404s, feedback, demo | Wave H1 | -| S6 | Auth write/admin routes lack rate limits | bulk, labels, table-facts, ingestion/*, eval-cases, jobs | Wave H2 | -| S7 | Document list offset max `1_000_000` | `documents/route.ts` | Wave H3 | -| S8 | Upload reserves max body when Content-Length absent | `upload/route.ts` | Wave H4 | -| S9 | Authed stream summarize excludes public docs | `answer/stream` → `summarizeDocument` owner-only | Wave H5 | -| S10 | Bulk returns raw DB `error.message` | `documents/bulk/route.ts` | Wave D5 | -| S11 | `/api/jobs` can return demo jobs when env missing | `jobs/route.ts` | Wave H6 | +| ID | Finding | Evidence | Remediation wave | +| --- | ----------------------------------------------------------- | --------------------------------------------------------- | ---------------- | +| S1 | Authed public-doc DTOs leak `storage_path` / `content_hash` | `documents/route.ts`, `documents/[id]/route.ts` | Wave D1 | +| S2 | Auth UI trusts `getSession()`; APIs use `getUser` | `src/lib/supabase/client.tsx` | Wave D3 | +| S3 | Public-upload quarantine pool high blast radius if enabled | `upload/route.ts` + pool owner | Wave D4 | +| S4 | Auth 401 envelope `{ error }` only | `supabase/auth.ts` vs `http.ts` | Wave H1 | +| S5 | Many hand-rolled `{ error }` omit `code`/`message` | upload, doc 404s, feedback, demo | Wave H1 | +| S6 | Auth write/admin routes lack rate limits | bulk, labels, table-facts, ingestion/\*, eval-cases, jobs | Wave H2 | +| S7 | Document list offset max `1_000_000` | `documents/route.ts` | Wave H3 | +| S8 | Upload reserves max body when Content-Length absent | `upload/route.ts` | Wave H4 | +| S9 | Authed stream summarize excludes public docs | `answer/stream` → `summarizeDocument` owner-only | Wave H5 | +| S10 | Bulk returns raw DB `error.message` | `documents/bulk/route.ts` | Wave D5 | +| S11 | `/api/jobs` can return demo jobs when env missing | `jobs/route.ts` | Wave H6 | ### 4.2 Ingestion / indexing diff --git a/docs/audit/repo-audit-2026-07-01.md b/docs/audit/repo-audit-2026-07-01.md index 1b5175fe1..23a6ccfef 100644 --- a/docs/audit/repo-audit-2026-07-01.md +++ b/docs/audit/repo-audit-2026-07-01.md @@ -44,7 +44,7 @@ The dominant theme is **clinical-safety / data-integrity in the answer path**: s `src/lib/retrieval-selection.ts:473` · privacy/clinical · CONFIRMED · safe_cleanup=false -- **Defect:** `annotateResultWithSelection` writes back `Math.max(originalScore, candidate.score)` as `hybrid_score`, so any net-negative `resultBoost` (outdated −0.24, review_due −0.12, unverified −0.08, poor-extraction −0.12) never lowers the score consumers see — the floor is meant to let intent "rescue" _raise_ a score, but it also blocks all penalties. +- **Defect:** `annotateResultWithSelection` writes back `Math.max(originalScore, candidate.score)` as `hybrid_score`, so any net-negative `resultBoost` (outdated −0.24, review*due −0.12, unverified −0.08, poor-extraction −0.12) never lowers the score consumers see — the floor is meant to let intent "rescue" \_raise* a score, but it also blocks all penalties. - **Failure:** An `outdated` chunk with base 0.70 → ~0.46 after penalties gets floored back to 0.70. `evaluateEvidenceCoverageGate` (`rag.ts:3233`, gate ~0.6) then treats the stale guideline as strong and presents it with high confidence instead of demoting it. - **Fix:** Don't floor at the original when `resultBoost` is negative — persist the clamped boosted score so freshness/validation penalties reach coverage gating. @@ -204,21 +204,21 @@ working tree of `claude/cool-wiles-12aade` (uncommitted, nothing pushed). 42 fil Five parallel review agents then audited the fix diff itself. They confirmed the bulk of the changes sound and surfaced **12 follow-up findings — all fixed** in the same working tree: -| # | Finding (introduced/incomplete) | Resolution | -| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| R1 | **HIGH** — H3's un-floored penalties **compounded** across the pipeline's 2–3 selection passes (`baseScore` re-read the penalized `hybrid_score`) | resolved by discarding the H3 change and aligning `retrieval-selection.ts` verbatim with PR #118 (relevance-first, no metadata penalties — see H3 row). Residual note: POSITIVE intent boosts still compound mildly across passes on main's design (pre-existing, eval-validated); a `preSelectionScore` idempotency fix was prototyped and withdrawn — reintroduce only gated on the golden eval. | -| R2 | M2's whole-word `mg`/`mcg` no longer matched digit-attached doses ("10mg"), and could re-cancel dosing via M3 | `(?:\b | \d)` digit-adjacent allowance (regression test "clozapine 100mg…") | -| R3 | L3's aggregate `none` could contradict per-source "nearby" chips for purely-semantic matches and trip the danger governance banner | aggregate `nearby` also granted when relevance score ≥ 0.5 | -| R4 | H1 incomplete — rich-mode prompts show table-fact **metadata** snippets (`accessible_table_markdown`/`table_text_snippet`/`cells`) not covered by the corpus | included in both `tableFactText` (verification) and `tableFactQuoteText` (quotes) | -| R5 | H2's rescued noisy fragments inflated `provenanceScore` past 0.42, flipping `useful` false and blanking text that previously survived | provenance computed over baseline-kept fragments only; thresholds count toward `clinicalSignalScore` | -| R6 | `x10` threshold branch matched "Rx100"/"0x10" | lookbehind guard `(?>>>>>> theirs +======= +>>>>>>> theirs +# Repository-wide review remediation completion plan — 2026-07-24 + +## Objective + +Complete every outstanding finding from the 2026-07-19 repository-wide review sweep with the smallest safe patches, clear ownership boundaries, and local/offline proof before any provider-backed gate. + +## Non-negotiables + +- Keep unrelated work out of each patch. +- Use Node 24/npm 11 and the existing npm lockfile before trusting typecheck, lint, tests, build, or installed Next docs. +- Do not change prompts, retrieval ranking, source formatting, auth, RLS, deployment secrets, or provider configuration unless that batch explicitly requires it. +- Do not run Supabase/OpenAI/live eval/hosted CI/release commands without explicit approval. +- Keep the formatting drift as a separate final pass so behavior diffs stay reviewable. + +## Issue map + +| ID | Finding | Primary risk | Fix batch | Done when | +| --- | -------------------------------------------------------------------------------------------- | ---------------------------------- | --------- | ------------------------------------------------------------------------------------------------------ | +| F1 | Non-stream `/api/answer` accepts `summaryMode` but runs normal RAG | Clinical/source contract drift | Batch 1 | Non-stream either uses governed document summary or rejects `summaryMode` with 4xx; focused tests pass | +| F2 | Stream `summaryMode` can validate one `documentIds` scope and summarize another `documentId` | Scope integrity mismatch | Batch 1 | Summary mode accepts only exact selected document scope; mismatch test fails closed | +| F3 | PR Policy runs for `main` only while CI also runs for `release/**` | Release governance gap | Batch 2 | PR Policy branch filter mirrors CI protected PR branches | +| F4 | Action pin checker ignores composite action files | Supply-chain guardrail gap | Batch 2 | Checker scans `.github/actions/**/action.yml?` and self-test covers composite `uses:` | +| F5 | Local runtime/dependencies are not ready | Verification unreliable | Batch 0 | Node 24/npm 11 active, `npm ci` complete, Next docs and local binaries present | +| F6 | 27-file Prettier drift | Review noise | Batch 5 | Dedicated formatting-only diff; `format:check` and `git diff --check` pass | +| F7 | Href-less mockup anchor | Accessibility affordance mismatch | Batch 3 | Anchor is a real link or a non-interactive element | +| F8 | Favourites disabled controls are focusable `aria-disabled` buttons | Keyboard/AT confusion | Batch 3 | Native disabled or explicit accessible disabled pattern is used | +| F9 | Differential density controls rely on `title="Soon"` | Unclear unavailable UI | Batch 3 | Visible accessible explanation or controls removed until implemented | +| F10 | `.npmrc allow-scripts=true` warns as unknown npm config | Tooling noise/future npm fragility | Batch 4 | Intent documented or line removed after confirming no repo consumer | + +## Execution sequence + +### Batch 0 — Verification prerequisites first + +**Why first:** All later fixes need representative local checks. + +**Patch scope:** Prefer no repo file changes. If environment docs are needed, keep them docs-only. + +**Steps** + +1. Switch to Node 24/npm 11 using `.nvmrc`, host tool manager, or the repo container image. +2. Run `node scripts/check-node-engine.cjs`. +3. Run `npm ci` without changing package manager or lockfile. +4. Confirm: +<<<<<<< ours +<<<<<<< ours +>>>>>>> theirs +======= +>>>>>>> theirs +======= +>>>>>>> theirs + - `node -v && npm -v` + - `test -f node_modules/typescript/bin/tsc` + - `test -f node_modules/next/dist/bin/next` + - `test -d node_modules/next/dist/docs` +<<<<<<< ours +<<<<<<< ours +<<<<<<< ours +5. Read only the relevant installed Next docs before any Next/config code change. + +**Verification** + +- `npm run check:runtime` +- `npm run typecheck` + +**Risk control** + +- Do not modify lockfiles during this batch unless `npm ci` reports lockfile/package inconsistency. +- Do not run provider-backed checks. + +## Batch 1 — Fix answer `summaryMode` contract drift + +**Purpose:** Remove the clinical/source-governance mismatch first. + +**Smallest preferred fix** + +1. In `src/lib/validation/answer-request.ts`, tighten summary mode so `summaryMode: true` requires exactly one `documentId` and rejects `documentIds` unless it is absent or exactly `[documentId]`. Reject filters in summary mode unless product explicitly wants filtered document summaries. +2. In `src/app/api/answer/route.ts`, either: + - preferred: call the same governed `summarizeDocument(documentId, ownerId, { signal })` path used by streaming; or + - fallback: reject `summaryMode` on the non-stream endpoint with a clear 400. +3. In `src/app/api/answer/stream/route.ts`, validate summary scope against the exact `documentId` before `resolveSearchScope` can use a conflicting `documentIds` array. +4. Add focused tests in `tests/private-access-routes.test.ts`: + - non-stream `summaryMode` uses `summarizeDocument` or rejects clearly; + - stream `summaryMode` rejects mismatched `documentId`/`documentIds`; + - stream `summaryMode` rejects filters that exclude/conflict with the selected document, if filters are disallowed. + +**Verification** + +- `npm run test -- tests/private-access-routes.test.ts -t "summaryMode"` +- `npm run test -- tests/rag-answer-fallback.test.ts tests/answer-response.test.ts` +- `npm run eval:rag:offline` + +**Risk control** + +- Do not change answer prompts, ranking, citation formatting, or retrieval algorithms in this batch. +- Preserve owner/access-scope behavior and fail closed on ambiguous summary scope. + +## Batch 2 — Fix CI governance coverage gaps + +**Purpose:** Make release PR governance and action pin enforcement match actual executable CI surface. + +**Smallest actions** + +1. In `.github/workflows/pr-policy.yml`, add `"release/**"` to `pull_request_target.branches` so PR Policy mirrors CI PR branches. +2. In `scripts/check-github-action-pins.mjs`, extend discovery to include: +======= +======= +>>>>>>> theirs +======= +>>>>>>> theirs +5. Before any Next/framework code change, read the relevant installed guide in `node_modules/next/dist/docs/`. + +**Verification ladder** + +1. `npm run check:runtime` +2. `npm run typecheck` +3. Stop and triage if either fails before touching product code. + +**Regression guard:** No lockfile or dependency edits unless `npm ci` proves the manifest/lockfile is inconsistent. + +### Batch 1 — Answer `summaryMode` contract and scope integrity + +**Why second:** This is the highest clinical/source-governance risk. + +**Patch scope** + +- `src/lib/validation/answer-request.ts` +- `src/app/api/answer/route.ts` +- `src/app/api/answer/stream/route.ts` +- `tests/private-access-routes.test.ts` + +**Smallest safe implementation** + +1. Add a shared summary-mode validation invariant: `summaryMode: true` requires `documentId`; `documentIds` must be absent or exactly `[documentId]`; filters are rejected unless a product owner explicitly wants filtered summaries. +2. Prefer making non-stream `/api/answer` call the same governed `summarizeDocument(documentId, ownerId, { signal })` path as streaming. If wiring the response parity is unexpectedly large, choose the safer fallback: reject non-stream `summaryMode` with a clear 400. +3. In stream route, validate the summary invariant before calling `resolveSearchScope`, so conflicting `documentIds` cannot satisfy scoping for another document. +4. Keep all other answer behavior unchanged: no prompt changes, ranking changes, citation formatting changes, or telemetry schema expansion unless needed for the tests. + +**Focused proof** + +1. `npm run test -- tests/private-access-routes.test.ts -t "summaryMode"` +2. `npm run test -- tests/rag-answer-fallback.test.ts tests/answer-response.test.ts` +3. `npm run eval:rag:offline` + +**Done criteria** + +- Non-stream summary requests no longer silently run normal RAG. +- Stream mismatched `documentId`/`documentIds` requests fail closed. +- Existing normal answer tests still pass. +- Offline RAG fixtures remain clean. + +### Batch 2 — CI governance and action-pin guardrails + +**Why third:** These are high-leverage governance/supply-chain fixes with low product risk. + +**Patch scope** + +- `.github/workflows/pr-policy.yml` +- `scripts/check-github-action-pins.mjs` +- existing or new local self-test fixture for the checker + +**Smallest safe implementation** + +1. Add `"release/**"` to PR Policy `pull_request_target.branches`. +2. Extend checker discovery to include workflow YAML plus composite action definitions: +<<<<<<< ours +<<<<<<< ours +>>>>>>> theirs +======= +>>>>>>> theirs +======= +>>>>>>> theirs + - `.github/workflows/*.yml` + - `.github/workflows/*.yaml` + - `.github/actions/**/action.yml` + - `.github/actions/**/action.yaml` +<<<<<<< ours +<<<<<<< ours +<<<<<<< ours +3. Add a self-test or fixture to prove unpinned external `uses:` inside a composite action fails the checker. + +**Verification** + +- `npm run check:github-actions` +- `npm run check:pr-policy` +- If a script self-test is added: run the focused test/script directly before the broad checks. + +**Risk control** + +- Do not change workflow job permissions, tokens, checkout refs, or hosted CI behavior beyond branch coverage and local static checking. +- Do not call GitHub APIs or rerun hosted CI without explicit confirmation. + +## Batch 3 — Fix small UI/accessibility remnants + +**Purpose:** Remove low-risk misleading controls without broad redesign. + +**Smallest actions** + +1. Replace the href-less mockup `Table 3` anchor with a real in-page link if a target exists, otherwise a styled `span`. +2. For favourites “Recent” and “Add favourite”, use native `disabled` or a non-button status pattern. Keep the existing visual treatment as much as possible. +3. For differential `Compact`/`Detailed`, replace `title="Soon"` as the only explanation with visible accessible text or remove the disabled toggle until implemented. + +**Verification** + +- `npm run test:focused -- --files src/components/master-document-flow-mockups.tsx,src/components/clinical-dashboard/favourites-hub.tsx,src/components/differentials/differential-presentation-workflow-page.tsx` +- If UI tests are selected or behavior is visibly changed: `npm run ensure`, then `npm run verify:ui`. + +**Risk control** + +- Do not redesign the surfaces. +- Do not introduce new state, routing, or feature activation. + +## Batch 4 — Decide `.npmrc` `allow-scripts=true` + +**Purpose:** Remove noisy/future-fragile npm config only after confirming intent. + +**Smallest actions** + +1. Search for repo tooling that reads `allowScripts` or `allow-scripts`. +2. If no repo tool consumes `.npmrc` `allow-scripts=true`, remove only that `.npmrc` line. +3. If it is intentional, keep it and add a short docs comment/README note explaining the consumer and warning tradeoff. + +**Verification** + +- `npm -v` +- `npm run check:runtime` +- `npm run format:check -- --ignore-unknown` is not an existing script; do not invent flags. Use `npm run format:check` only after dependencies are installed. + +**Risk control** + +- Do not change package manager, lockfile, install strategy, or dependency versions. + +## Batch 5 — Dedicated formatting-only pass + +**Purpose:** Eliminate Prettier drift without hiding behavior changes. + +**Smallest actions** + +1. Start from a clean worktree after Batches 1-4 are merged or parked. +2. Run `npm run format`. +3. Review that only formatting changes occurred. + +**Verification** + +- `npm run format:check` +- `git diff --check` +- If formatted source files include behavior-sensitive areas, run their focused tests from previous batches. + +**Risk control** + +- Keep this as its own commit/PR. +- Do not mix with clinical/RAG or CI logic changes. + +## Final handoff gate after all local batches + +Run only after Node 24, dependencies, and focused checks are clean: + +1. `npm run verify:cheap` +2. `npm run verify:pr-local` +3. If UI batch changed visible behavior: `npm run ensure` then `npm run verify:ui` +4. If answer/RAG behavior changed: `npm run eval:rag:offline` + +## Approval-required follow-up gates + +Ask before running any of these: +======= +======= +>>>>>>> theirs +======= +>>>>>>> theirs +3. Add a self-test that would fail if an unpinned external `uses:` in a composite action is ignored. + +**Focused proof** + +1. Checker self-test or direct script test for composite action discovery. +2. `npm run check:github-actions` +3. `npm run check:pr-policy` + +**Done criteria** + +- Release PRs are covered by PR Policy. +- Composite action `uses:` lines are scanned. +- Existing pinned local actions still pass. + +**Regression guard:** Do not change workflow permissions, token use, checkout refs, or hosted CI behavior beyond static coverage. + +### Batch 3 — UI/accessibility remnants + +**Why fourth:** User-facing polish, but lower clinical/governance risk than Batches 1-2. + +**Patch scope** + +- `src/components/master-document-flow-mockups.tsx` +- `src/components/clinical-dashboard/favourites-hub.tsx` +- `src/components/differentials/differential-presentation-workflow-page.tsx` + +**Smallest safe implementation** + +1. Convert the mockup `Table 3` anchor to a real in-page link only if a stable target exists; otherwise use a styled `span`. +2. Change favourites “Recent” and “Add favourite” to native `disabled` buttons, or replace them with non-button status pills if they are roadmap-only. +3. Replace `title="Soon"` on differential density controls with visible accessible “Coming soon” text, or remove the disabled toggle until the feature exists. + +**Focused proof** + +1. `npm run test:focused -- --files src/components/master-document-flow-mockups.tsx,src/components/clinical-dashboard/favourites-hub.tsx,src/components/differentials/differential-presentation-workflow-page.tsx` +2. If visual UI changes are material: `npm run ensure`, then `npm run verify:ui`. + +**Done criteria** + +- No href-less actionable-looking anchor remains in the touched mockup. +- Unavailable controls no longer create misleading keyboard/AT affordances. +- No new route/state behavior is introduced. + +### Batch 4 — `.npmrc allow-scripts=true` decision + +**Why fifth:** It is a tooling warning, not product behavior. + +**Patch scope** + +- `.npmrc` +- optional docs note only if keeping the setting intentionally + +**Smallest safe implementation** + +1. Search for repo consumers of `allowScripts` and `allow-scripts`. +2. If no repo consumer needs `.npmrc allow-scripts=true`, remove only that line. +3. If it is needed, keep it and document the exact consumer and expected npm warning. + +**Focused proof** + +1. `rg -n "allowScripts|allow-scripts" . --glob '!node_modules'` +2. `npm run check:runtime` +3. Any install check only after Node 24 is active. + +**Done criteria** + +- Either the npm warning source is removed, or the repo documents why it remains. +- No dependency versions, lockfile entries, or package-manager choices change. + +### Batch 5 — Formatting-only cleanup + +**Why last:** Keeps logic/security/clinical diffs reviewable. + +**Patch scope** + +- Only files changed by Prettier. + +**Smallest safe implementation** + +1. Start from a clean worktree after Batches 1-4 are complete or parked. +2. Run `npm run format`. +3. Review the diff for formatting-only changes. + +**Focused proof** + +1. `npm run format:check` +2. `git diff --check` +3. If Prettier touched behavior-sensitive test/source files, rerun the focused checks from the relevant earlier batch. + +**Done criteria** + +- `format:check` passes. +- The commit contains no semantic edits. + +## Final local handoff gate + +Run after all batches are complete under Node 24 with dependencies installed: + +1. `npm run verify:cheap` +2. `npm run verify:pr-local` +3. `npm run eval:rag:offline` if Batch 1 changed answer behavior and it was not already run after final rebasing. +4. `npm run verify:ui` if Batch 3 changed visible UI behavior. + +## Provider-backed approval gates + +Do not run these without explicit confirmation: +<<<<<<< ours +<<<<<<< ours +>>>>>>> theirs +======= +>>>>>>> theirs +======= +>>>>>>> theirs + +- `npm run check:supabase-project` +- `npm run check:production-readiness` +- `npm run eval:retrieval:quality` +- `npm run eval:rag -- --limit 15` +- `npm run eval:quality -- --rag-only` +- `npm run verify:release` + +<<<<<<< ours +<<<<<<< ours +<<<<<<< ours +## Recommended execution order + +1. Batch 0 — prerequisites. +2. Batch 1 — answer `summaryMode` clinical contract. +3. Batch 2 — CI governance/static supply-chain guardrails. +4. Batch 3 — UI/accessibility polish. +5. Batch 4 — `.npmrc` warning decision. +6. Batch 5 — formatting-only pass. +7. Final handoff gate. + +This order fixes the highest clinical/governance risk first, avoids formatting noise during logic review, and keeps provider-backed uncertainty outside local development until explicit approval is given. +======= +======= +>>>>>>> theirs +======= +>>>>>>> theirs +## Recommended PR split + +1. PR A: Batch 0 docs/prerequisite proof only if environment setup requires repo documentation; otherwise no PR. +2. PR B: Batch 1 answer `summaryMode` contract and tests. +3. PR C: Batch 2 CI governance/action-pin guardrails. +4. PR D: Batch 3 UI/accessibility remnants. +5. PR E: Batch 4 `.npmrc` warning decision. +6. PR F: Batch 5 formatting-only cleanup. + +This split keeps clinical behavior, CI governance, UI polish, npm config, and formatting isolated so regressions are easier to detect and revert. +<<<<<<< ours +<<<<<<< ours +>>>>>>> theirs +======= +>>>>>>> theirs +======= +>>>>>>> theirs diff --git a/docs/audit/repo-wide-review-sweep-2026-07-19.md b/docs/audit/repo-wide-review-sweep-2026-07-19.md new file mode 100644 index 000000000..2d1dad694 --- /dev/null +++ b/docs/audit/repo-wide-review-sweep-2026-07-19.md @@ -0,0 +1,188 @@ +# Repository-wide review sweep — 2026-07-19 + +## Scope + +This was a broad static repository sweep of `/workspace/Database` on branch `work`, combining the repo workflow guidance, local static commands, and six parallel specialist review passes. The goal was to identify high-confidence issues and pragmatic improvement opportunities without touching provider-backed services. + +This sweep was not a literal proof that every repository line is defect-free. The practical coverage was broad static inspection plus targeted high-risk passes across security/auth/privacy, RAG/clinical answers, database/RLS, UI/accessibility, CI/release automation, dependencies/build/runtime, and local verification hygiene. + +## Review passes used + +1. Repository instructions and review protocol preflight. +2. `database-flightplan` risk/verification planning. +3. Security/auth/privacy API boundary review. +4. RAG/retrieval/answer/source-governance review. +5. Database/migrations/RLS/Postgres-function review. +6. Frontend/UI/accessibility/routing review. +7. CI/workflow/release automation review. +8. Dependency/build/runtime/Next.js config review. +9. Local format hygiene check. +10. Local dependency/tool availability check. +11. Local runtime/typecheck/lint feasibility check. +12. Broad static pattern scan for risky markers, auth/env usage, disabled controls, anchors, and provider-sensitive surfaces. + +## High-confidence findings + +### P1 — Non-stream `/api/answer` accepts `summaryMode` but silently runs normal answer generation + +- **Files:** `src/lib/validation/answer-request.ts`, `src/app/api/answer/route.ts`, `src/app/api/answer/stream/route.ts`, `src/lib/rag.ts`. +- **Trigger:** Send `POST /api/answer` with `summaryMode: true` and a valid `documentId`. +- **Expected:** The route should either call the governed full-document summary path or reject `summaryMode` as unsupported for the non-stream endpoint. +- **Actual risk:** The non-stream route parses a schema that accepts `summaryMode`, but the route has no summary branch and always calls normal answer generation. This can omit later document sections, produce different clinical/source behavior than the streaming summary route, and create API contract drift. +- **Smallest proof/check:** Add a focused route test that mocks `summarizeDocument` and `answerQuestionWithScope`, posts `summaryMode: true` to `/api/answer`, and asserts the summary path is used or the request is rejected with 4xx. +- **Suggested fix:** Make the non-stream endpoint share the streaming summary behavior, or reject `summaryMode` explicitly on `/api/answer`. + +### P1 — PR metadata policy does not run for `release/**` pull requests + +- **Files:** `.github/workflows/pr-policy.yml`, `.github/workflows/ci.yml`, `.github/pull_request_template.md`. +- **Trigger:** Open, synchronize, or mark ready a PR targeting `release/`. +- **Expected:** Release-targeted PRs should receive the same trusted PR metadata/evidence policy as main-targeted PRs, unless an equivalent release gate exists. +- **Actual risk:** CI runs on PRs targeting `main` and `release/**`, but the PR policy workflow only runs for `main`. Release PRs can therefore appear CI-covered while skipping evidence, verification, rollback, and high-risk governance metadata checks. +- **Smallest proof/check:** Inspect workflow trigger branches: CI includes `release/**`; PR policy does not. +- **Suggested fix:** Add `release/**` to the PR policy workflow branch trigger, or document and enforce an equivalent release-specific policy. + +### P2 — Streaming `summaryMode` can validate one document scope but summarize a different `documentId` + +- **Files:** `src/lib/validation/answer-request.ts`, `src/app/api/answer/stream/route.ts`, `tests/private-access-routes.test.ts`. +- **Trigger:** Send `/api/answer/stream` with `summaryMode: true`, `documentId: A`, and `documentIds: [B]`. +- **Expected:** Summary-mode scope validation should be tied to exactly the document being summarized, or mismatched fields should be rejected. +- **Actual risk:** Scope resolution prefers `documentIds` when present, but the summary branch later summarizes `documentId` directly. This can pass selected-scope validation using one document set while generating a clinical summary for another document. +- **Smallest proof/check:** Add a route test asserting `summaryMode` rejects mismatched `documentId`/`documentIds`, or only summarizes when `documentIds` is absent or exactly `[documentId]`. +- **Suggested fix:** Tighten summary-mode validation to require exactly one `documentId` and no conflicting `documentIds` or filters unless explicitly supported. + +### P2 — GitHub Actions pin guard does not scan composite action files + +- **Files:** `scripts/check-github-action-pins.mjs`, `.github/actions/setup-node-cached/action.yml`, `.github/actions/setup-ui-e2e/action.yml`, `.github/workflows/ci.yml`. +- **Trigger:** A future change introduces a mutable external `uses:` reference inside `.github/actions/**/action.yml`. +- **Expected:** The action pin guard should scan both workflow files and reusable local composite action definitions. +- **Actual risk:** The current pin checker discovers only `.github/workflows` files, while CI executes local composite actions that can contain third-party `uses:` entries. Future unpinned composite-action dependencies could bypass the guard. +- **Smallest proof/check:** Add a fixture/self-test with an unpinned `uses:` inside a composite action and assert `npm run check:github-actions` fails. +- **Suggested fix:** Extend discovery to `.github/actions/**/action.yml` and `.github/actions/**/action.yaml`. + +### P2 — Current shell runtime is Node 20 while the repo requires Node 24 + +- **Files:** `package.json`, `.nvmrc`, `scripts/check-node-engine.cjs`, `Dockerfile`, `Dockerfile.worker`. +- **Trigger:** Run install, runtime, build, typecheck, lint, or Next.js checks in the current shell. +- **Expected:** Local verification should run under Node 24 and npm 11 as declared by the project. +- **Actual risk:** Verification under Node 20 is not representative and fails the repo's own runtime guard. CI/production Docker images are aligned to Node 24, so Node 20 results are environment-limited. +- **Smallest proof/check:** `node scripts/check-node-engine.cjs` fails in the current shell with Node 20.20.2. +- **Suggested fix:** Use Node 24 for local validation, or add a plain-Node preflight that reports the mismatch before deeper checks. + +### P2 — `node_modules` is absent, blocking local dependency/build/Next.js validation + +- **Files:** `package.json`, `scripts/deployment-boot-smoke.mjs`, `AGENTS.md`. +- **Trigger:** Run build, lint, typecheck, runtime checks, or attempt to read Next.js docs under `node_modules/next/dist/docs/` in this checkout. +- **Expected:** Installed dependencies should be present before relying on local dependency/build/Next.js conclusions. +- **Actual risk:** The required Next.js docs and local binaries are absent, causing checks to fail before application logic is reached. +- **Smallest proof/check:** `test -d node_modules` reports absent; `npm run typecheck` fails because `node_modules/typescript/bin/tsc` is missing. +- **Suggested fix:** Run `npm ci` under Node 24 before deep local validation, or add friendlier prerequisite diagnostics. + +### P2 — Formatting drift across 27 files + +- **Files:** 27 files reported by `npm run format:check`, including docs, scripts, app routes, components, libraries, and tests. +- **Trigger:** Run `npm run format:check`. +- **Expected:** Prettier should pass cleanly or formatting drift should be intentionally documented. +- **Actual risk:** Formatting drift creates noisy diffs and weakens review signal, especially during broad clinical/retrieval changes. +- **Smallest proof/check:** `npm run format:check` exits 1 and reports 27 files with style issues. +- **Suggested fix:** Run `npm run format` in a dedicated formatting-only change after confirming the drift is not intentional. + +### P3 — Href-less anchor in document-search mockup is styled as actionable but has no `href` + +- **File:** `src/components/master-document-flow-mockups.tsx`. +- **Trigger:** Open the document-search/source mockup and try to interact with the styled `Table 3` inline reference. +- **Expected:** If actionable, it should be a real link/button; if not, it should not be an anchor. +- **Actual risk:** Sighted users see link styling while keyboard and assistive-tech users do not receive a real link target. +- **Smallest proof/check:** Static search for `` tags without `href` identifies this inline anchor. +- **Suggested fix:** Replace with `href="#table-3"` or a non-interactive ``. + +### P3 — “Coming soon” favourites controls are keyboard-focusable despite `aria-disabled` + +- **File:** `src/components/clinical-dashboard/favourites-hub.tsx`. +- **Trigger:** Keyboard navigate through `/favourites` toolbar controls. +- **Expected:** Unavailable actions should use native `disabled` or provide a clear focusable explanatory pattern. +- **Actual risk:** Keyboard users encounter controls that look/feel operable but cannot perform an action. +- **Smallest proof/check:** Static inspection shows `aria-disabled="true"` without native `disabled` on the relevant buttons. +- **Suggested fix:** Use native `disabled`, a non-button status/pill, or a fully documented focusable disabled pattern with visible explanation and activation guards. + +### P3 — Differential density controls are disabled UI relying on `title="Soon"` + +- **File:** `src/components/differentials/differential-presentation-workflow-page.tsx`. +- **Trigger:** Open a differential presentation workflow and inspect the `Compact`/`Detailed` toolbar controls. +- **Expected:** Unimplemented toolbar controls should either be hidden, clearly marked as coming soon, or exposed with an accessible explanation. +- **Actual risk:** The only explanation is `title="Soon"`, which is unreliable for keyboard, touch, and assistive technology users. +- **Smallest proof/check:** Static inspection shows native disabled controls with `aria-disabled` and `title="Soon"`. +- **Suggested fix:** Add visible accessible explanation, remove until implemented, or present current density as static state. + +### P3 — `.npmrc` contains npm-unknown `allow-scripts=true` + +- **Files:** `.npmrc`, `package.json`. +- **Trigger:** Run npm scripts in this checkout. +- **Expected:** npm project config should be recognized or documented if intentionally consumed elsewhere. +- **Actual risk:** npm prints warnings that `allow-scripts` is unknown and may stop working in the next major npm version, creating noisy logs and possible future install friction. +- **Smallest proof/check:** npm commands print `Unknown project config "allow-scripts"`. +- **Suggested fix:** Confirm whether repo tooling consumes it; remove or document it accordingly. + +## Areas with no high-confidence defect found in this sweep + +- Database/RLS/Postgres functions: no high-confidence issue found in static review. Tables appear to have RLS enabled, broad privileges are revoked before service-role grants, sensitive functions use explicit `search_path`, and storage policy shape is owner-scoped. +- Security/auth/privacy API boundaries: no high-confidence issue found in static review. High-risk mutation routes generally require authenticated/admin context or use intentional public access with rate limiting and owner/public scope guards. +- Server actions: no server-action marker was found in the searched source set. + +## Improvement backlog + +1. Add a static service-role API boundary check for mutation routes. +2. Add focused anonymous signed-url privacy tests for document and image URLs. +3. Add a health/readiness disclosure matrix for anonymous, invalid-token, valid-token, and admin callers. +4. Maintain a checked-in allowlist of intentional public API surfaces. +5. Add a no-server-actions guard if server actions remain intentionally unused. +6. Add a static table/RLS parity check. +7. Add a SECURITY DEFINER `search_path` static check. +8. Add storage policy shape assertions for owner-scoped paths. +9. Generate or maintain an RLS/ACL privilege manifest for review diffs. +10. Make summary-mode answer validation a discriminated contract. +11. Extract shared answer request execution between stream and non-stream routes. +12. Add explicit route parity tests for summary mode. +13. Include summary-mode scope used for generation in telemetry/client payloads. +14. Mirror PR policy branch coverage with CI branch coverage. +15. Scan composite actions in the GitHub action pin guard. +16. Add CI-scope self-test coverage for release-branch governance. +17. Add composite-action pin guard fixtures. +18. Add a plain-Node environment prerequisite check for Node/npm/dependency presence. +19. Clarify how agents should locate Next.js version docs when `node_modules` is not installed. +20. Add container-level runtime boot smoke coverage for final Docker image layout. +21. Standardize unavailable UI action semantics. +22. Avoid `title` as the only explanation for disabled/coming-soon clinical workflow controls. +23. Prefer SPA navigation over `window.location.assign` for internal dashboard actions where practical. +24. Add a lightweight static accessibility guard for href-less anchors and disabled-control patterns. +25. Run a dedicated formatting-only pass after approval. + +## Checks run in the main audit session + +- `pwd && find .. -name AGENTS.md -print && git status --short --branch && git rev-parse --abbrev-ref HEAD && git log --oneline -5` +- `cat .agents/skills/workflows/SKILL.md && cat .agents/skills/database-flightplan/SKILL.md && cat .agents/skills/session-lifecycle/SKILL.md` +- `cat AGENTS.md | sed -n '1,220p' && sed -n '1,220p' docs/codex-review-protocol.md && cat package.json` +- `npm run workflow:flightplan -- --write-evidence` +- `git status --short && git diff --name-only && find . -maxdepth 3 -name AGENTS.md -print && rg -n "TODO|FIXME|HACK|XXX|SECURITY|BUG|throw new Error\(|console\.log|any\b|@ts-ignore|eslint-disable|dangerouslySetInnerHTML|innerHTML|eval\(|process\.env\.|SERVICE_ROLE|SUPABASE_SERVICE|OPENAI_API_KEY|TODO" src scripts worker supabase tests .github docs --glob '!node_modules' --glob '!package-lock.json'` +- `npm run check:knip` +- `npm run format:check` +- `npm run typecheck` +- `npm run lint` +- `npm run check:runtime` + +## Check results and limitations + +- `npm run workflow:flightplan -- --write-evidence` passed and wrote ignored local evidence under `.local/workflow-evidence/`. +- `npm run format:check` failed with formatting drift in 27 files. +- `npm run check:knip` failed because `knip` was not found; `node_modules` is absent. +- `npm run typecheck` failed because `node_modules/typescript/bin/tsc` is missing. +- `npm run lint` failed because the concurrent heavy-run lock was held by the typecheck command. This is an agent execution error caused by launching two heavy repo commands in parallel. +- `npm run check:runtime` failed because `tsx` could not be resolved from missing `node_modules`. +- Provider-backed/live checks were not run: `npm run check:supabase-project`, `npm run check:production-readiness`, live retrieval/answer evals, hosted CI, and GitHub API actions remain confirmation-required by repository policy. + +## Follow-up priority + +1. Fix summary-mode route contract issues first because they can affect clinical answer/source behavior. +2. Fix release PR policy coverage and composite action pin scanning because they affect governance and supply-chain protection. +3. Restore a correct local verification environment: Node 24, npm 11, installed dependencies. +4. Run a dedicated formatting pass or decide whether existing drift should remain. +5. Then run `npm run verify:cheap` and risk-selected PR-local checks under the correct environment. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 45a7bf0e2..cd09b5fd6 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -2,6 +2,8 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD has not changed. +This file is append-only. Never rewrite or delete an existing review record; append a correction or superseding record instead. Git uses the `union` merge driver for this file so concurrent appended records are retained automatically. After merging, keep all distinct records and remove exact duplicates only. + ## Lookup Procedure 1. Identify the target branch or ref from the user request. If no target is named, use the current branch. @@ -701,3 +703,36 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (Option A wave verdict — no code change; #1040 merged as cde6c5c) | canary run 29827012719 (#61, main cde6c5c) vs banked #60 (29800029819) | OPTION A WAVE ADOPTED — FIRST FULLY-GREEN 44-CASE CANARY IN PROGRAM HISTORY (Blocking failures: None). (1) Option A payoff EXCEEDED: citation_failure_rate 0.0227→0; the neuroleptic-side-effect-escalation case flipped from wrong-doc→failed-generation→1-citation-fallback to **strong route, successful gpt-5.6-sol generation, passed in 15.4s with no fallback marker** — the rescued S3 retrieval fixed generation itself, not just the citation count; expected_source_hit 0.6364→0.6591. (2) Golden held exactly as the blast-radius analysis promised: 36/36 PASS, content_recall 1.0, mrr@10 0.8921 BYTE-IDENTICAL to the pre-wave baseline (zero ordering movement — no golden case fires the predicate), irrelevant@10 0.1083→0.0917 (slightly better). (3) Parity payoff PARTIAL: monitoring targeting 1/5→2/5 (olanzapine-lai flipped — previously called a retrieval-depth residual; quetiapine-dose also flipped on the dose side); lithium-range (232ch) + metabolic (73ch, byte-identical answer to #60) did NOT flip despite offline-proven fixes — their live chunk sets evidently contain no admissible schedule sentence even under the widened gate → reclassified as retrieval-depth/live-content residuals joining adhd; below the ≥3/5 target but strictly improved, no regression anywhere. Dose 2/5 vs 2/4: same passing count, applicable set grew (new quality-metformin-renal-dosing miss = eval-set churn, not regression). (4) No-worse EXCEEDED: relevance 0.5333→0.6 (the two-step watch-item slide FULLY REVERSED to the #58 level), targeting_rate 0.6667→0.6957, fail_closed 0.9 held, readability/artifact_leaks 1.0, route ceilings 0, grounded 1.0, unsupported_correct 1.0, numeric 0, p95 22.8s, red_result 3/3. Adoption per the measured-gain rule: primary goal achieved, three case flips, relevance recovered, zero regressions. Residual queue: monitoring retrieval-depth trio (lithium-range/metabolic/adhd), E-3d H2 discards, weekly ANSWER_CASE_LIMIT 8→44 raise now unblocked (gate would be green), comparison-class coverage. Wave spend +~$2-4 → Phase E + Option A total ~$12-20 of ≤$20. | Evidence: run #61 job log read (Threshold Status: None; Answer Metrics; neuroleptic diagnostics row; targeting metric_rates + 6-miss list; golden 36 PASS lines + summary). Revert drill NOT triggered. | | 2026-07-21 | claude/clinical-kb-pwa-review-asi3wb (PR: I9 weekly coverage raise) | see PR head | ADDENDUM 5 post-green item I9 (plan-authorized "after reds fixed"): weekly scheduled canary ANSWER_CASE_LIMIT default 8→44 — the Sunday 18:00 UTC cron now guards the FULL answer-quality case set instead of the first 8 (both #57 blocking reds historically lived OUTSIDE the first 8, leaving the weekly gate blind to them). Unblocked by run #61 proving the citation gate green on the full 44. Cost: est +$1-2/week (user-authorized in the plan). Contract test pin updated in lockstep (eval-canary-workflow.test.ts). Dispatch shapes unchanged (input override still wins); operational-risk diff, plain-revert rollback. | check:github-actions PASS; check:ci-scope PASS; check:gate-manifest PASS (20/20); eval-canary-workflow contract 4/4; prettier clean; no provider calls | | 2026-07-21 | claude/database-governance-audit-10b6ed (PR #1051: source-governance audit — safe subset) | cee396730 | Governance-metadata observability + UI display + provenance flow test; no ranking/retrieval/generation surface touched. | IMPLEMENTED + handed off (not a review of prior work). Resolved audit #1 (logger.warn on unrecognized enum values; return value unchanged), #2 (review_due_source added to frontendVisibleWarningCodes → answer-level badge; warning-severity, no refusal impact), #9 (source_metadata retained on safety-finding citations + governance pill in SafetyFindingsListContent), #13 (new tests/provenance-flow.test.ts: DB-normalize→governance→client payload sources+safety citations→render policy). Deferred #4/5/6/8/10 (RAG-protected ranking/selection/LLM-context/cache — need live eval-canary+approval), #11/#5 flag debt (D5/D4), #3 (is_public schema/RLS), #7 (conflict-detection scope), #12 (canary automation). Rebased onto origin/main (was 18 behind; conflict-free — none of the 18 commits touched the 8 files). PR-policy CI green (confirmed no ragRankingPatterns match). | verify:pr-local exit 0 (351 files/3129 tests, production build, client-bundle secret scan, offline RAG fixtures 36/36); typecheck + lint + prettier green. verify:ui NOT run locally: pre-existing globals.css Tailwind/Turbopack dev-compile error (git-clean, unrelated; prod build passed) — CI Production UI job covers it. check:production-readiness deferred (offline env/config validator; PR changes no env/secret/config inputs; secretless worktree). No provider calls. | +| 2026-07-19 | all remote feature branches and registered worktrees against `origin/main` through PR #899 | 8242fa63d5f5b79fc770c9ae4f633e3a784b80e1 | branch/worktree cleanup, useful-work recovery, and protected-main merge closure | Deleted 122 stale or closed remote feature refs with exact SHA leases; four additional merged PR branches were removed by the protected-main PR workflow. Removed 32 obsolete, superseded, or merge-proven worktree registrations. Recovered useful dirty RAG work into PR #901 (deterministic and opt-in semantic reranking) and PR #902 (retrieval phase latency telemetry), preserved follow-up decisions in `docs/process-hardening.md`, and recovered four missing historical review rows. PRs #897, #899, #901, and #902 were merged with green exact-head checks. Correction: the original zero-unresolved-thread statement was inaccurate for PR #901; a subsequent full-repository audit recorded two unresolved semantic-rerank threads, whose code findings are remediated by the 2026-07-19 P2 audit-fix entry below. A detached full-repo-review worktree is deliberately retained because its ownership/activity could not be safely disproved; one unregistered `node_modules` junction residue is also retained because deletion was denied by local safety policy. | Fresh fetch/prune; full GitHub PR/check/thread inventory; `git worktree list --porcelain`; cherry-pick-aware right-only logs; exact leased remote deletes; exact-old-value local ref deletes; clean-worktree, path, and merged-PR proof before every removal. PR #899 local proof: focused Vitest 31/31, changed-file ESLint, `verify:cheap` 317 files / 2,879 tests, and `verify:ui` 239/239; exact-head hosted checks all passed. PR #901 local proof: `verify:cheap` 316 files / 2,870 tests; PR #902 focused Vitest 8/8 plus ESLint and typecheck. No OpenAI, Supabase, live clinical, deployment, or production-data workflow ran; provider-backed semantic canary evaluation remains approval-gated. | +| 2026-07-19 | codex/fix-p2-audit-20260719 | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 + reviewed working diff | full-repository remediation of audit findings P2-6 through P2-23 across RAG, cancellation, privacy/API validation, PDF extraction, auth durability, offline/CI verification, Factsheets, Therapy Compass, and review records | Remediated all 18 recorded P2 findings with scoped code and regression tests. Semantic rerank signals and safety identifiers now survive answer ranking; source summaries reject embedded instructions; shared search/embedding/answer work respects per-caller cancellation; public search omits internal storage paths and document chunk validation fails closed; JS PDF extraction enforces dimensions and aggregate budgets before copying; transient auth validation outages retain local user data; offline release and CI PDF prerequisites are deterministic; Factsheet print/save state is honest and persistent; Therapy artifact actions are capability-aware and catalogue routes load a compact generated index; the prior PR #901 thread claim is corrected. No remaining high-confidence P2 was found in the reviewed working diff. Remote review-thread disposition was not attempted because GitHub API interaction requires separate confirmation. | `verify:cheap` passed 318 files / 2,891 tests / 1 skipped; final PR-local constituent run passed format, lint, typecheck, and 318 files / 2,892 tests / 1 skipped; production Next.js build generated 1,682 pages and the client-bundle secret scan passed; `verify:ui` passed 239/239 Chromium tests; offline RAG fixtures passed 36 cases / 21 suites and offline RAG eval passed 295 tests; focused changed-surface Vitest and DOM suites passed; CI-scope, Therapy index, offline-release dry-run, and `git diff --check` passed. The PR-local wrapper's first build attempt was correctly blocked by the identity-verified dev server; after stopping only that isolated server, the build and remaining RAG fixture step passed directly. No OpenAI, Supabase, GitHub, hosted-CI, deployment, or production-data workflow ran. | +| 2026-07-19 | origin/main 24-hour merged window (d1937d78e..ef042cacd, ~30 PRs incl. #853/#859/#861/#865/#868/#871–#874/#879/#885–#888/#890–#894/#896) | ef042cacd | integrated post-merge regression audit of the full 24h window (code review + design/performance/defect angles) | No P0/P1 regression found at the merged tip. One confirmed P2: the new settings surface (`use-app-preferences.ts` + `settings-dialog.tsx`) presents jurisdiction, population, answer-style, landing, home-content, compact-citations, and all notification preferences as live controls, but no consumer reads them — only density/motion (html attributes + globals.css) and theme are functional; a clinician selecting "Conservative" answer style reasonably but wrongly believes generation changed. One plausible P2/P3: `AuthProvider.initializeSession` now requires a live `getUser()` round-trip, so a transient network failure on load resolves a valid stored session to signed_out (INITIAL_SESSION replay is also skipped); the deliberate stale/tampered-token defense does not distinguish retryable network errors. One P3: `/medications` legacy redirect drops the query string while `/applications` and `/differentials/presentations` preserve theirs. Cleared after inspection: worker image-placement dedupe (key symmetric on both sides), title-word purge/scope migration (matches its replayed review), RPC-layer-only cancellation consolidation (intentional per PR #861), services route-chunk fix intact with no other heavy client value-imports (type-only imports verified), forced-colors ButtonFace/ButtonText flip, codex-autofix/pr-policy workflow changes conform to AGENTS.md (pin retained, rename-aware routing, workflow_sha checkout), audit-metadata allowlist exhaustive over the closed AuditAction union, answer-stream merged abort signals. Environment note (not a repo defect): the session-start hook skips `npm install` when node_modules exists, so this window's dependency bumps left the container stale and `verify:cheap` failed at typecheck until `npm ci`; the hook should compare a lockfile hash. | `npm ci` then typecheck clean and full Vitest 2828 passed / 1 failed / 2 skipped — the sole failure is the long-baselined container-only `tests/pdf-extraction-budget.test.ts` artifact (hosted-CI-green through #826/#835/#872/#890). First `verify:cheap` run passed every static guard (runtime, actions-pin, ci-scope, ci-triage, pr-policy, sitemap, brand, type-scale, icon-scale, function-grants, owner-scope, lint) before the stale-deps typecheck stop. No OpenAI, Supabase, deployment, or provider-backed check ran. | +| 2026-07-19 | origin/main foreign merges post-#896 landing (541da7b #871 remediation + f4557ca #892 policy parsing; explicit user review request) | ef042ca6a34d33862936e77e4b71068978382c1e | Bug review of recent main changes (diff-review protocol) | One P2 confirmed and fixed in PR #905: #892's widened heading matcher ended a required PR-body section at ANY next heading, so `###` sub-structure inside `## Verification` truncated the section and false-rejected valid bodies (fail-closed; repo template unaffected; repro via direct evaluatePullRequestPolicy probe old-vs-new). No P0/P1. Cleared after verification: `src/lib/client-env.ts` (no env leakage; production demote-to-demo removal deliberate and fail-safe — upload gating still locked via canUsePrivateApis), applications/medications redirect routes (fixed targets, 307+HEAD alias, no open-redirect/header-injection), test infra (no weakened assertions; route-coverage spec added to all projects), and the three biggest #871 UI diffs read inline (ClinicalDashboard upload tablist roving-tabindex + hydration-safe useSyncExternalStore role switch; visual-evidence unavailable-source rows became real non-interactive elements; favourites library demo-gates prototype items with sound menu keyboard nav). Residual (report-only): pr-policy `section()` remains fence-unaware (headings inside fenced code can satisfy required-section checks — pre-existing class, author-controlled attestation surface); dev-only Turbopack persistent-cache staleness served an old globals.css compile across restarts twice this session (fixed by setting `.next` aside). | Reviewer fan-out: general lane completed with concrete probes (pr-policy self-test, node repro on old parser from f4557ca^, adversarial body probes); UI lane agent lost to session limit and re-done inline on the three biggest diffs, leaning on the merged tree's green gates (verify:ui 236-passed run in this session covers #871's own new specs). No provider-backed checks run. | +| 2026-07-19 | claude/clinical-kb-pwa-review-asi3wb (PR #905; commits b2afe66 visuals + 6531178 policy + this ledger follow-up) | 6531178c1a3427dfd58c4bfcf8e29020c5731179 | PWA install/update notice redesign (all breakpoints) + review-follow-up policy fix | Redesigned the five PWA notices (install, update, iOS hint, offline, restored) as glass lux cards: per-type semantic icon tiles, heading-ink titles, corner dismiss buttons, reduced-motion-safe 280ms entrance. Deliberate placement per screen size: phones keep the bottom card above the fixed composer (thumb zone, safe areas); ≥640px floats a 25rem card bottom-right; ≥1280px moves the stack to a top-right toast under the header (same 4.25rem+safe-area offset constant as the mode-menu popover) with the animation direction flipped. Copy, roles, and button names unchanged. Plus the outline-aware pr-policy section parser fix from the same-session review (see the review row above). | Focused vitest pwa-lifecycle.dom 9/9 + pwa-manifest 8/8; `check:pr-policy` self-test green incl. new sub-heading case; `verify:cheap` 2828/2831 (sole fail = known container-only pdf-extraction-budget artifact); `test:e2e:pwa` privacy/offline green (installability fail = known container `in-incognito` artifact); `verify:ui` 236 passed/2 failed (the two long-baselined container artifacts); production build + client-bundle secret scan + bundle budget within tolerance (1293.4 vs 1278.6 KiB baseline); visual evidence at 390/768/1440 light+dark+offline in session scratchpad. Dev caveat recorded: Turbopack persistent `.next` cache served stale globals.css across restarts twice; fixed by setting the cache aside. No provider-backed checks run. | +| 2026-07-19 | `origin/main` through PR #903 plus fixed 48-hour PR snapshot (`#689`–`#902`) | 4034d2e60ebb6616130ff17bf3cb69368f36f8f6 | whole-repository, all-lens regression and PR-activity review | Changes requested. No P0. Confirmed five P1 defects: stale publication approvals are not bound to reviewed document state; invalid supplied credentials can become anonymous uploads; pooled duplicate uploads expose another uploader's metadata; readiness is fail-open for database usability errors; and settings promise clinical tailoring/alerts with no consumers. Eighteen P2 findings cover semantic rerank effectiveness/privacy, summary prompt trust, PDF resource limits, cancellation, auth-state loss, public storage-path exposure, query validation, provider-boundary/CI/test gaps, factsheet/Therapy behavior, Therapy startup cost, and the PR #903 ledger's incorrect claim that PR #901 has zero unresolved threads. GitHub GraphQL still reports two current unresolved P2 threads on merged PR #901. | Exact tree `68a58f6f..4034d2e60`: 217 commits, 566 files, +59,742/-8,242. Fixed snapshot inventory: 213 PRs created and 25 older PRs updated. On `a871dd765`, `verify:cheap` passed (317 files/2,879 tests), offline RAG passed (21 suites/294 tests), and production build plus required Chromium passed (1,682 pages; 239/239). PR #899 exact head `8242fa63d` has the same full local proof and green hosted checks; PR #903 is docs-only and passed `git diff --check`, docs links, and docs script references. Production-readiness CI, design-system, env parity, workflow guard, and offline audit passed. `docs:check-index` remains advisory-red and full-range `git diff --check` reports four intentional Markdown hard breaks plus three SQL whitespace lines. No OpenAI, Supabase, deployment, live clinical, or provider-backed release command ran. | +| 2026-07-19 | local branches and worktrees after PRs #905 and #907 | e377ab1aed44d56c303eede80572c1df82ddcd6e | final local branch/worktree cleanup and useful-history preservation | Reduced 153 local branches to `main` plus the two actively edited task branches. Deleted 150 redundant refs using cherry-pick containment, exact merged-PR heads, synthetic no-op merge trees, exact duplicate-head retention, merged-PR commit association, and explicit supersession review. Removed two clean obsolete worktrees and retained only the active auth/Supabase and P2-remediation worktrees for their owners. Archived the final 66 superseded historical heads in a verified 76,395,256-byte Git bundle before ref deletion. PRs #905 and #907 merged through protected `main`; PR #906 subsequently merged through protected `main` and its remote branch was removed. | Fresh fetch/prune and PR inventory; exact-old-value `git update-ref`; `git merge-tree --write-tree`; GraphQL commit-to-PR association for 178 commits; reverse-patch checks against a detached fresh-main worktree; verified bundle `Database-local-refs-before-final-cleanup-20260719-0525.bundle`; final branch/worktree/remote checks. No OpenAI, Supabase, deployment, live clinical, or production-data workflow ran. | +| 2026-07-19 | claude/audit-recent-changes-kde66i (audit-remediation follow-up to the ef042cacd audit row) | see PR head | remediation of the three 2026-07-19 audit findings | Fixed all three findings from the 24-hour merged-window audit. (1) Inert settings honesty (P2): every preference the app does not yet consume — jurisdiction, population, answer style, landing, both home-content toggles, compact citations, and all three notification toggles — now renders an explicit "Saved for later — not active yet" marker in `settings-dialog.tsx`; the functional appearance/density/motion rows stay unmarked. Wiring the preferences into answer generation was deliberately NOT done here (clinical-behavior change requiring governance review); the new dom test documents the contract for flipping a control live. (2) Auth offline resolution (P2/P3): `resolveInitialAuthState` gains `verificationUnavailable`, set from `isAuthRetryableFetchError(getUser().error)`, so an unreachable auth server keeps the stored session signed in while a reachable server that rejects the token still resolves signed_out; server-side bearer validation is unchanged. (3) `/medications` redirect (P3): now preserves the sanitized q/focus/run search context with the same allowlist as the root legacy-mode redirect. | Focused Vitest 30/30 across `settings-inert-preferences.dom` (new), `private-client-auth` (3 new cases), `audit-navigation-auth-regressions` (query-preservation case), `app-preferences`, and `site-map`. Full `verify:cheap` run recorded on the PR. No OpenAI, Supabase, deployment, or provider-backed check ran. | +| 2026-07-19 | claude/audit-recent-changes-kde66i (preference wiring + session-start hook follow-up to the #906 remediation) | see PR head | wiring the wireable inert preferences live and fixing the stale-node_modules session-start gap | Wired three of the seven remaining inert preferences into real behavior and removed their "Saved for later" markers: (1) Default landing view — `GlobalSearchShellClient` applies a one-shot `router.replace` to the saved landing mode (`search`→documents, `browse`→tools via `landingModeForPreference`) on a bare "/" load only; explicit mode/query/run params always win, and the dashboard's existing URL-sync effect performs the switch. (2) Recent searches on home — `AnswerEmptyState` now gates its recent-query chips on `showRecentOnHome`. (3) Compact citations — the answer source capsule drops its text label to icon+count when `compactCitations` is on, with the "No direct source found" warning explicitly exempted so compact mode can never hide a missing-source signal. Still marked inactive with reasons documented in the test contract: jurisdiction/population/answer-style (wiring them into answer generation is provider-eval-gated per the confirmation boundary), saved-protocols-on-home (no protocols module exists), and the three notification toggles (no delivery infrastructure). Separately, `.claude/hooks/session-start.sh` now stamps the `package-lock.json` sha256 into `node_modules/.session-start-lock-hash` after `npm ci` and reinstalls when the lockfile no longer matches, closing the stale-container gap that faked a typecheck regression during the 24h audit. | New `tests/answer-preferences.dom.test.tsx` (recents gate on/off, compact capsule display incl. the missing-source exemption, landing mapping) and the updated `settings-inert-preferences.dom` contract (3 rows moved inert→functional) pass with `app-preferences` and `private-client-auth`: 26/26 focused. Full typecheck, scoped zero-warning ESLint, and the full Vitest suite recorded on the PR; `bash -n` on the hook. `check:production-readiness` not run: no secrets in this container (documented demo-mode expectation) and no answer-generation, retrieval, or source-governance logic changed — the capsule change is presentational with the missing-source warning locked by test. No OpenAI, Supabase, deployment, or provider-backed check ran. | + +## 2026-07-19 — work repository-wide review sweep + +- Branch/ref: work +- HEAD: 39378863a5d713bfdeb617377a90319ae75810d4 +- Scope: Repository-wide static review sweep across security/auth/privacy, RAG/clinical answers, database/RLS, UI/accessibility, CI/release automation, dependencies/build/runtime, and local verification hygiene. +- Outcome: Findings recorded in docs/audit/repo-wide-review-sweep-2026-07-19.md. Highest severity: P1 summary-mode non-stream route contract drift; P1 release PR policy coverage gap. +- Checks: npm run workflow:flightplan -- --write-evidence (pass); npm run format:check (failed existing formatting drift); npm run check:knip (failed missing node_modules); npm run typecheck (failed missing TypeScript binary); npm run lint (failed heavy-run lock because typecheck was active); npm run check:runtime (failed missing tsx/node_modules). Provider-backed checks skipped per confirmation boundary. + +## 2026-07-23 — work quick current-state follow-up review + +- Branch/ref: work +- HEAD: 570a507d099c64fcf9db1d27ddbef5f5e1f142d3 +- Scope: Quick follow-up review of issues raised in the 2026-07-19 repository-wide review sweep, plus local static checks requested in chat. +- Outcome: Several prior findings remain reproducible in the current tree: non-stream /api/answer still accepts summaryMode without a summary branch; stream summaryMode can still scope documentIds separately from summarized documentId; PR policy still targets only main while CI targets main and release/**; action pin checker still scans only workflow YAML files; local shell remains Node 20 with node_modules absent; Prettier drift still reports 27 files. check:github-actions and check:pr-policy self-tests pass but do not cover the remaining coverage gaps. +- Checks: node/npm/dependency presence probe; static source inspection of answer request/routes, CI/PR policy triggers, action pin checker, UI/accessibility remnants, .npmrc/package engines; npm run check:github-actions && npm run check:pr-policy && git diff --check (pass); npm run format:check (failed existing formatting drift). No provider-backed checks run. + +## 2026-07-24 — work search chrome behaviour review + +- Branch/ref: work +- HEAD: bcf4571dd37005622dbef7aae0e2374afafb6b0f +- Scope: Targeted review of search bar/header/footer chrome behaviour after the edge-to-edge phone dock fix, plus durable repo rules for page-adaptive search chrome. +- Outcome: No new P0/P1 search chrome defect found in the static review. Fixed one regression hazard: a stale ClinicalDashboard comment still instructed a 0.75rem hidden dock pad despite the implementation/tests requiring 0rem. Added durable search chrome behaviour rules in AGENTS.md and docs/search-chrome-behaviour.md, with a static guard tying the remembered rules to the hidden-reserve contract. +- Checks: dependency shortcut section count; git diff --check; targeted rg for stale 0.75rem hidden-pad source wording (only negative test assertions remain); targeted Vitest command attempted but blocked by missing node_modules/vitest under Node 20.20.2 in this container. No provider-backed checks run. diff --git a/docs/clinical-governance.md b/docs/clinical-governance.md index e48c31c4a..1bef9275c 100644 --- a/docs/clinical-governance.md +++ b/docs/clinical-governance.md @@ -47,3 +47,13 @@ Use the `.github/pull_request_template.md` clinical governance section for any c - Document organization coverage is an operational invariant: after ingestion or generated-label reclassification, run `npm run check:document-label-coverage` and require zero indexed documents missing generated `site` or `document_type` labels. - **Application-layer cross-owner denial** (service-role routes enforce `owner_id` scoping in code) is covered by `tests/private-access-routes.test.ts` and `tests/private-rag-access.test.ts` (unowned document detail/signed-url/rename rejected; listing and search scoped to the authenticated owner). - **Follow-up:** add a live DB-level RLS integration test that connects as two real authenticated users via the publishable (anon) key and asserts owner B cannot read owner A's rows. This needs a seeded test project/harness and is tracked as a remaining item. + +## Source Provenance Taxonomy + +Source provenance is an issuer-identity signal only. It is independent from currency, local validation, extraction quality, document type, and clinical relevance; combinations such as `Official · Outdated` and `Trusted · Unverified` are valid and must stay visible as separate caveats. + +- **Official**: authenticated documents issued by a recognised Western Australian hospital or WA health-service network, including CAHS, WACHS, EMHS, NMHS, and SMHS. Official does not mean current, locally approved, or clinically relevant. +- **Trusted**: every other recognised authority, including BMJ, NICE, WHO, Australian national bodies, other Australian state health departments, generic WA Health material, and WA specialty services such as CAMHS. +- **Unclassified**: unknown authority, ambiguous identity, conflicting metadata, publisher aliases without compatible jurisdiction, or registry summaries. Registry summaries retain their separate identity and never inherit Official or Trusted provenance from linked or nearby authorities. + +Authority must come from registered publisher codes or compatible canonical publisher/jurisdiction metadata. Arbitrary title, body, or extracted text claims do not establish source authority. diff --git a/docs/codex-review-protocol.md b/docs/codex-review-protocol.md index fda9de1db..5117780b7 100644 --- a/docs/codex-review-protocol.md +++ b/docs/codex-review-protocol.md @@ -30,7 +30,7 @@ Use this protocol for every Codex review, audit, bug hunt, PR review, release-re - During an automatic resolve task, work only existing unresolved Codex threads. Do not start a new review, add standalone findings, or request another review. - After fixing or fully dispositioning a thread, start the reply with ``; the workflow will close that exact thread. Do not use the marker when human input or new authorization is required, and leave that blocked thread open with a concise reason. - Ask before any OpenAI, Supabase, GitHub/GitLab, hosted CI, or provider-backed workflow. -- After any completed branch/PR review, update `docs/branch-review-ledger.md` with date, branch/ref, HEAD, scope, outcome, and checks. This ledger append is allowed even during a pure review. +- After any completed branch/PR review, append to `docs/branch-review-ledger.md` with date, branch/ref, HEAD, scope, outcome, and checks. The ledger is append-only: never edit or delete an existing record; append a correction or superseding record instead. This ledger append is allowed even during a pure review. ## Severity Guide diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index e8dd14f7b..e1d864c5b 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -177,6 +177,6 @@ live project need explicit operator approval. GUC pattern (`20260702160000` precedent). 10. **Migration-chain fidelity** (affects Supabase Preview/branches, not live): 13 keys where the chain diverges from schema.sql — buckets are only - created by schema.sql, `documents`/`ingestion_jobs` updated_at trigger + created by schema.sql, `documents`/`ingestion_jobs` updated*at trigger variants, post-legacy-drop embedding-fields index set, - `document_chunks_content_trgm_idx` shape, `rag_visual_eval_*` shapes. + `document_chunks_content_trgm_idx` shape, `rag_visual_eval*\*` shapes. diff --git a/docs/ingestion-concurrency-fix-workorder.md b/docs/ingestion-concurrency-fix-workorder.md index 2ad02e707..8a7da69fb 100644 --- a/docs/ingestion-concurrency-fix-workorder.md +++ b/docs/ingestion-concurrency-fix-workorder.md @@ -310,13 +310,13 @@ arm (a deep-memory pass after an agent pass). scoping only units+cards leaves agent memory-cards detached via `section_id ON DELETE SET NULL`. -**Required work (design, not patch):** decide the section_index ownership model +**Required work (design, not patch):** decide the section*index ownership model between the agent and deep-memory — e.g. give the agent a disjoint index range or stop it writing `document_sections`, or make deep-memory's section write an `on conflict (document_id, section_index)` upsert with a defined winner. Then scope index_units + memory_cards deletes with the NULL-safe filter above. -_Retrieval-affecting → eval gate. Touches `src/lib/deep-memory.ts` and the edge -agent._ +\_Retrieval-affecting → eval gate. Touches `src/lib/deep-memory.ts` and the edge +agent.* **Files:** `src/lib/deep-memory.ts`, `supabase/functions/indexing-v3-agent/*`, possibly a migration. diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 234667bc7..fc1995b64 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -48,6 +48,7 @@ removed after current-main verification; it is not missing recommended work. database/RAG/clinical/privacy expertise; Operator = named provider/product/legal authority. - **Estimate:** focused active time, excluding approval, hosted runtime, soak, and review waits. +<<<<<<< ours | Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition | | ----: | ---------------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `#059` | A1 | Operator security + independent reviewer | Immediate approved security window | 15–30 min plus verification | Revoke the GitHub token previously exposed in chat, confirm the old token is rejected, and store any replacement only through the intended credential store. Never record the value; stop before provider or secret-store action without approval. | @@ -85,6 +86,44 @@ removed after current-main verification; it is not missing recommended work. | 33 | `#063` | A3 | High — product architecture + privacy | Only when the product owner wants to evaluate the feature | 0.5–1 day | Write a product/privacy/persistence brief for “Current Clinical Work” before storage or UI implementation. Stop if demand or safe persistence cannot be established. | +======= +| Order | ID(s) | Acuity | Capability | When | Estimate | Outcome, gate, verification, and stopping condition | +| ----: | ---------------------- | -------- | ------------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | `#059` | A1 | Operator security + independent reviewer | Immediate approved security window | 1–3 hours plus verification | Verify every reported exposed credential (GitHub, OpenAI, Supabase service role/database, E2E) is retired; rotate anything still valid and update only intended secret stores. Never record values; stop before provider action without approval. | +| 2 | `#053` | A1 | Operator — legal/privacy | Start now; finish before real patient use/privacy-approved release | 4–8 hours internal; 1–6 weeks elapsed | Execute DPAs; decide ZDR/residency; obtain cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not change public copy before approval. | +| 7 | `#067` | A3 | High — test reliability | Next flake-hardening window | 1–2 hours | Reproduce the load-sensitive reconciliation-preflight subprocess timeout, instrument its lifecycle, and make the smallest deterministic harness fix. Do not raise the global timeout or bypass the shared heavy-test lock without causal proof. | +| 9 | `#019` | A2 | Specialist — RAG answer pipeline | Local reproducer now; behavior change after `#051`/`#023` | 0.5–1 day reproducer | Reproduce admission-source loss in the fallback layer using PR #1096’s source shape. Any behavior change needs protected review and an approved baseline/post canary; stop if independently non-reproducible. | +| 10 | `#054` | A2 | Standard locally; Operator hosted | Local safety identifier now; hosted next approved window | 15–30 min local; 1–2 hours hosted | Presence-check and fill confirmed secret/config gaps with distinct per-environment values. Never record values. Require clean readiness/secret checks; stop on ambiguous environment or project identity. | +| 11 | `#022` | A2 | Operator — clinical governance + Specialist | Decision-ready | 1–2 hours policy; 0.5–1 day first ten | Decide BMJ attestation policy and review the ten highest-impact local documents. Record reviewer/evidence/time; stop after ten and remeasure warning debt. | +| 12 | `#051`, `#023` | A2 | Specialist — RAG diagnostics | After scheduled 2026-07-26 run | 2–4 hours | With GitHub-read approval, compare structured canary/browser/irrelevant-at-10 artifacts without dispatching a rerun. Record deterministic/provider/latency deltas and disposition residuals; stop without spending. | +| 13 | `#018` | A2 | Specialist — clinical RAG/retrieval | After `#051`/`#023`, one mechanism at a time | 1–2 days diagnosis | Give lithium, ADHD, and metabolic residuals separate current-main reproducers and candidates. Behavior canaries require approval; stop any item without a deterministic reproducer or on regression. | +| 14 | `#029` | A2 | Specialist — answer quality/clinical safety | After `#051`/`#023` and `#018` | 0.5–1 day inventory; 1–3 days per fix | Re-enumerate current fallback stubs and fix one causal cluster at a time without weakening grounding/citation gates. Stop if a change merely makes the metric easier to pass. | +| 37 | `#069` | A2 | Specialist — answer quality/clinical safety | After `#051`/`#023`, before closing `#018`, `#019`, or `#029` | 2–4 hours inventory; provider runtime if approved | Re-enumerate all current fallback-stub and residual answer-quality cases from structured artifacts, map each to a causal cluster, and request explicit approval for baseline/post answer-quality or canary validation before marking fixed. Stop on weaker grounding, citations, or source governance. | +| 15 | `#001` | A2 | Specialist — retrieval/ranking | After `#051`/`#023` and rollout approval | 0.5–1 day plus canary | Keep semantic reranking off unless an approved ambiguity comparison preserves 36/36, recall 1.0, zero per-case regressions, and shows measured gain; otherwise record keep-off and stop. | +| 16 | `#025` | A2 | Operator — Railway/GitHub/chat/Supabase | Next approved observability window | 1–3 hours/channel | Choose owned deployment, CI, ingestion, and SLO alerts; mock first, then one approved controlled provider event/channel. The merged Supabase trigger remains inert until its verified inputs are configured. Stop without an accountable responder. | +| 17 | `#064` | A2 | High — frontend/browser | After higher-acuity local fixes; before the release UI gate | 4–8 hours | Preserve the isolated dirty formulation/contrast patch, reconcile its intent against current `main`, and run focused Playwright plus `verify:ui`. Stop rather than overwriting unrelated work or weakening access-control assertions. | +| 18 | `#055` | A2 | Specialist release owner + Operator | Before next full-confidence release/handoff | 2–4 hours plus runtime | On one exact SHA, run local/provider gates, Firefox/WebKit, required hosted CI, and close actionable GitHub threads. Stop at first failure and rerun only the repaired smallest gate. | +| 19 | `#056` | A2 | Operator — Supabase/Railway + Specialist | After cost/ownership approval | 0.5–1 day | Provision isolated `Clinical KB Staging` with synthetic data and distinct secrets. Verify identity, schema, indexing, health, and data boundary; never copy production clinical documents. | +| 20 | `#057` | A2 | High — release/SRE + Operator | After `#056` | 2–4 hours plus soak | Run documented staging soak and rollback against an exact candidate. Retain latency/error/rollback evidence; stop on unsafe data, identity mismatch, or unowned rollback. | +| 21 | `#058` | A2 | Operator — production data + Specialist | Next approved production verification window | 30–60 min read-only; 1–2 hours if needed | Verify registry/differentials/medications are non-empty before writing; seed only confirmed gaps idempotently. Stop when healthy or owner/project identity is ambiguous. | +| 22 | `#007` | A3 | Operator decision + Standard frontend | When product chooses canonical Tools route | 15–30 min decision; 0.5 day | Align navigation, redirect, sitemap, and reachability around one entry point. Stop while the standalone page has an unresolved requirement. | +| 23 | `#011` | A3 | Operator — Supabase capacity | Immediately before first compute scale-up | 30–60 min plus observation | Switch Auth to percentage allocation, record before/after, and run approved advisor/health checks. Stop if no scale-up is planned. | +| 24 | `#017` | A3 | High — performance/browser | Before `#012`/`#013`/`#016`; approved live-site window | 1–2 hours | Capture reproducible mobile/desktop Lighthouse/Web-Vitals evidence and decide whether payload work is justified. Stop if metrics are acceptable or evidence is too noisy. | +| 25 | `#024` | A3 | High — Next.js/Playwright/WebKit | After `#051`/`#023` or real Safari reproduction | 0.5–1 day | Distinguish test interception from a Safari defect. Apply test-only correction only with a discriminating repro; keep access-control assertions meaningful. | +| 26 | `#033` | A3 | Specialist — prompt/source governance | After `#022` and `#051`/`#023` | 1–2 days plus approved eval | Design unknown-vs-adverse metadata wording and prompt tests. Require no supported-grounding drop and zero citation failures; stop on broad over-caveating or degradation. | +| 27 | `#037` | A3 | Operator — clinical/product + Standard | Next trust-policy review | 30–60 min; up to 0.5 day | Decide whether routine claims cap at medium trust. Record policy; if accepted, change only the flag/expectations and run focused tests. | +| 28 | `#012`, `#013`, `#016` | A3 | High — bundling/runtime performance | After `#017` or equivalent evidence | 0.5–2 days/route | Optimize only a production route with measured payload/render/motion harm. Require material gain plus focused, `verify:cheap`, and browser evidence; stop on small gain. | +| 29 | `#035` | A3 | Specialist — evidence rules | After a demonstrated missed conflict | 0.5–1 day design; code separate | Define a clinically reviewed conflict class with positive and negative fixtures. Stop if no bounded class can be shown; behavior change requires protected review. | +| 30 | `#027` | Optional | Operator — SRE/provider | When an owned external alert path is wanted | 1–2 hours | Decide vendor/cost/privacy/owner; if accepted, prove one non-PHI outage and recovery alert. Stop when no responder owns it. | +| 31 | `#028` | Optional | Specialist privacy/observability + Operator | After privacy/ownership/cost approval | 1–3 days | Define vendor/region/retention/redaction/sampling/source-map envelope before SDK work. Prove no clinical text, identifiers, or secrets leave; stop if unacceptable. | +| 32 | `#038` | Optional | High — product/design architecture | When a new comparison surface is approved | 0.5–1 day | Define a shared interaction contract without flattening mode-specific content. Stop when no concrete new surface exists. | +| 33 | `#040` | Optional | High — visual QA/accessibility | When baseline owner/update workflow exists | 1–2 days | Establish a small stable desktop/mobile/accessibility baseline set. Do not make it blocking if flake or maintenance cost outweighs detection value. | +| 34 | `#039` | Optional | High — frontend architecture | During a concrete catalogue-toolbar project | 0.5–1 day inventory; 1–3 days code | Converge only repeated toolbar behavior without flattening search semantics. Stop when there is no bounded implementation target. | +| 35 | `#063` | A3 | High — product architecture + privacy | Only when the product owner wants to evaluate the feature | 0.5–1 day | Write a product/privacy/persistence brief for “Current Clinical Work” before storage or UI implementation. Stop if demand or safe persistence cannot be established. | +| 36 | `#065` | A2 | High — document-viewer UI | Only when the user explicitly resumes the paused task | 0.5–1.5 days | Finish the compact source-text accordion, citation/search auto-open, print restoration, and 320/390/1280 px coverage. Keep the preserved branch untouched until explicit resume; no provider calls. | + + +>>>>>>> theirs ## Open items @@ -92,6 +131,7 @@ removed after current-main verification; it is not missing recommended work. > **RAG reconciliation correction (2026-07-23):** fresh current-main live evidence supersedes the broad diagnosis in #018. The three named misses are not one composer defect. Lithium reproduced an unrelated-table retrieval fast-path defect; ADHD still retrieves a relevant chart-heavy CAMHS source but exhausts the extractive route budget; metabolic retrieves the correct AKG source but selects schedule-free prose; #019 remains post-retrieval comparison source selection. A narrow lithium subject-evidence guard improved its targeting result from 0 to 1 with golden recall 1.0 and no reciprocal-rank regressions, but was reverted and rejected because the required full canary failed. Keep #029 open for the remaining fallback-stub cases. Do not combine these residuals or change ranking scores, comparator ordering, aliases, clamps, or semantic reranking without a separate reproducer and passing canary pair. +<<<<<<< ours | ID | Pri | Type | Summary | Detail / next action | Source | Added | | ---- | --- | ----- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- | | #059 | P1 | task | Verify containment of the GitHub token exposed in chat | **Outcome:** the exposed token can no longer authenticate. **Next:** in an approved GitHub security window, revoke the old token, verify rejection without printing it, and create a replacement only if required through the intended credential store. **Success:** GitHub evidence shows the old token retired, any replacement is minimally scoped and stored only where intended, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation | 2026-07-24 | @@ -137,6 +177,54 @@ removed after current-main verification; it is not missing recommended work. | #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | | #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | | #041 | P3 | rec | Extend the existing Factsheets reading model | Do not add a second patient-facing Factsheets mode. Future patient-content work should extend the existing Easy Read/Standard presentation and its accessibility/content contracts. Revisit only with a concrete user need and source-governance plan. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +======= +| ID | Pri | Type | Summary | Detail / next action | Source | Added | +| ---- | --- | ----- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| #059 | P1 | task | Verify containment of every credential reported exposed in chat | **Outcome:** every reported exposed credential is rejected or retired. **Next:** in approved security windows, verify and revoke or rotate the GitHub token, OpenAI key, Supabase service-role JWT, database password, and E2E credential; create replacements only when required and update only intended secret stores. **Success:** provider evidence confirms the old credentials cannot authenticate, replacements are distinct and minimally scoped, presence/readiness checks pass, and secret scans remain clean. **Stop:** no provider or secret-store action without approval; never print or paste values into Git, logs, issues, or chat. | session 2026-07-24 security reconciliation; AI Agent Target Manifest | 2026-07-24 | +| #063 | P3 | rec | Define “Current Clinical Work” before implementation | **Outcome:** decide whether a workspace combining saved comparisons, partial formulation work, recent tools, and pinned source sets is worth building. **Next:** write a product/privacy/persistence brief only; do not build storage or UI. **Success:** define users, data classes, lifecycle, cross-device expectations, deletion, failure states, demand evidence, and the smallest testable slice. **Stop:** close the idea if demand or safe persistence cannot be established. | session 2026-07-24; `src/lib/tools-catalog.ts` | 2026-07-24 | +| #064 | P2 | task | Reconcile the preserved browser and contrast patch | **Outcome:** the isolated dirty formulation/contrast patch is safely landed or explicitly dispositioned. **Next:** rebase its intent against current `main` without overwriting the worktree, then run focused Playwright coverage and `verify:ui`. **Success:** intended disabled/contrast behavior is accessible, browser assertions remain meaningful, and unrelated work is preserved. **Stop:** do not discard or auto-merge the dirty worktree; pause on ambiguous ownership or scope. | `agent/formulation-disabled-contrast`; session 2026-07-24 | 2026-07-24 | +| #065 | P2 | task | Complete the paused compact document source-text accordion | **Outcome:** the document viewer uses compact nested disclosures while retaining complete text, citation/search navigation, print behavior, and composer clearance. **Next:** only when the user explicitly resumes, reconcile `codex/chat-document-text-accordion-7cb4` with current `main` and complete the focused 320/390/1280 px tests. **Success:** default disclosures are closed; deep links and search open only the active passage; printing expands/restores state; no overflow. **Verify:** focused document-viewer Playwright, `verify:cheap`, `verify:ui`, and static production-readiness. **Stop:** remain paused until explicit user return; no provider calls. | paused document-viewer task; `codex/chat-document-text-accordion-7cb4` | 2026-07-24 | +| #066 | P2 | task | Land and prove the streamlined six-item sidebar | Implementation and the corrected Therapy wiring assertion are recoverable from remote branch `origin/codex/sidebar-test-fix-20260723` at full commit `cd54e68fbf7b07b5dffe3220e36af2caa528da54`; fetch it in a fresh checkout with `git fetch origin refs/heads/codex/sidebar-test-fix-20260723`. Focused sidebar/favourites suites pass 18/18. Remaining: run `verify:pr-local`, start the app and inspect desktop/tablet/mobile plus short-height scrolling and keyboard/focus behavior, run `verify:ui`, production build and bundle-budget checks, then open/merge the PR and prove the exact content is on `origin/main`. | remote branch `origin/codex/sidebar-test-fix-20260723`; commit `cd54e68fbf7b07b5dffe3220e36af2caa528da54`; session 2026-07-24 | 2026-07-24 | +| #067 | P2 | issue | Reconciliation preflight test times out under full-suite load | **Outcome:** the reconciliation preflight subprocess test is deterministic under the repository's serialized heavy-test workflow. During PR #1119 validation, the 30-second test timeout occurred twice under loaded full-suite execution, while the isolated file passed 5/5 and a separate `verify:cheap` full suite passed. **Next:** reproduce with timing around subprocess startup, output and teardown, then fix the smallest proven harness lifecycle cause. **Success:** repeated focused and full-suite runs complete without extending the global timeout. **Stop:** do not hide the cause by raising broad timeouts, adding retries, or bypassing the shared test lock. | `tests/reconciliation-preflight.test.ts`; PR #1119 validation; session 2026-07-24 | 2026-07-24 | +| #069 | P2 | task | Validate remaining answer-quality fallback findings before closure | **Outcome:** #018, #019, and #029 are closed only after current evidence proves the residual answer-quality failures are gone without weakening clinical grounding. **Next:** after #051/#023 artifact comparison, enumerate current fallback stubs and the lithium, ADHD, metabolic, admission/discharge residuals from structured artifacts; assign each case to a causal cluster; add deterministic local reproducers for any cluster selected for repair; then request explicit approval for baseline/post answer-quality or canary validation before marking fixed. **Success:** residual cases no longer emit source-backed review boilerplate, citations remain source-backed, and no retrieval or source-governance regression is introduced. **Stop:** do not close based on local metric wording improvements or a single unvalidated patch. | #018; #019; #029; previous fallback PR follow-up; session 2026-07-24 | 2026-07-24 | +| #051 | P2 | task | Stabilise the live answer-quality canary before more RAG tuning | Diagnostics landed in PR #1095: structured JSON/Markdown artifacts now record the actual checked-out SHA, run identity and latency context, and the offline trend tool separates content, provider-route and latency outcomes. First validating run `30018289898` recorded the expected tree and cost, with 36/36 retrieval green, but one report cannot establish variability; PR #1097 prevents a single failure being mislabeled as repeated. Next: compare the scheduled 2026-07-26 structured report with this run. Do not spend on an immediate retry or reapply the archived lithium guard before that comparison. | PR #1095; run `30018289898`; PR #1097; archive ref `refs/archive/rejected-rag/20260723/monitoring-subject-gate` | 2026-07-23 | +| #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | +| #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | +| #054 | P2 | task | Reconcile local and hosted secrets/config | Presence-check safety-identifier, query-hash, deep-probe, Supabase service-role, OpenAI, project-identity, and schedule settings; set only confirmed gaps with distinct per-environment values. Never record secret values. Provider reads/writes require approval. | `.env.example`; production-readiness warning; `docs/operator-backlog.md` | 2026-07-24 | +| #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | +| #056 | P2 | task | Provision isolated staging environment | After explicit cost/ownership approval, provision `Clinical KB Staging` Supabase and Railway tiers with distinct secrets and synthetic/non-clinical data. Verify identity, schema, indexing, health, and the production-data boundary. | `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-24 | +| #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | +| #058 | P2 | task | Verify production content before any seed write | Against `Clinical KB Database`, verify registry, differentials, and medications surfaces are non-empty before writing. Seed only confirmed gaps idempotently with approved owner/project identity and confirmation flags. | `docs/launch-operator-runbook.md`; `docs/operator-backlog.md` | 2026-07-24 | +| #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | +| #007 | P3 | rec | `/tools` vs `/?mode=tools` parallel Tools entry points | `/tools` (standalone `ApplicationsLauncherPage`) has no inbound in-app link; the sidebar Tools item uses `/?mode=tools`. Decide the canonical entry point and wire nav consistently, or drop the standalone `/tools` page + `/applications` redirect. Currently allowlisted in `tests/route-reachability.test.ts`. | `src/app/tools/page.tsx`; `src/app/applications/route.ts` | 2026-07-21 | +| #009 | P3 | rec | Confirm `/api/jobs` is intentionally server/ops-only | No client `fetch()` reaches `/api/jobs` (only tests import it). Confirm it is a deliberate ops/manual surface; if abandoned, remove it. | `src/app/api/jobs/route.ts` | 2026-07-21 | +| #010 | P3 | task | Un-built "Coming soon" controls across forms/favourites | ~10 disabled placeholders (forms refine/reset, favourites sort/add/new-set, move-to-set, remove-favourite). Correctly flagged (`aria-disabled` + "Coming soon"), not defects — wire when the underlying features land. | `forms-search-results-page.tsx`; `favourites-hub.tsx`; `favourites-command-library-page.tsx` | 2026-07-21 | +| #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up — **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | +| #012 | P3 | rec | Slim the lazy cross-mode differentials chunk | `cross-mode-differentials.ts` is dynamically imported (correctly code-split **out** of the initial/dashboard bundle — verified), but it pulls the full ~860 KB differentials snapshot (~125 KB gzip lazy chunk) just to build a tiny `{slug,title,clinicalHinge}` + presentations + aliases catalog. A precomputed lightweight index (generator + drift check, like the `specifiers-content` split / medications `fields=index`) would cut that lazy chunk ~5–10×. Not a bundle leak — an M-effort slim. | `src/lib/cross-mode-differentials.ts`; `src/components/clinical-dashboard/cross-mode-links.tsx:150`; session 2026-07-21 (build:analyze) | 2026-07-21 | +| #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search — needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod — exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | +| #016 | P3 | rec | "Big but not easy" structural + motion perf | Deferred larger levers: (a) nonce-CSP forces every product route to `ƒ Dynamic` (zero static generation) — evaluate Partial Prerendering / static shells for the static clinical catalogues (DSM/differentials/therapy/specifiers/formulation); (b) sidebar expand/collapse animates `grid-template-columns` (biggest smoothness cost, motion-gated — needs a transform-overlay rethink); (c) Therapy Compass fetches 692 KB / 2.5 MB JSON client-side (defer until interaction + confirm brotli); (d) settings/setup/admin dialogs static-imported into the home chunk (`next/dynamic` them). | session 2026-07-21 (build route table + design audit) | 2026-07-21 | +| #017 | P3 | task | Field Web-Vitals baseline via live Lighthouse | In-sandbox runtime vitals were blocked (prod server hard-requires Supabase secrets; dev-mode CLS measured excellent at 0.00–0.04, content-first pages 0.000). Run Lighthouse against `psychiatry.tools` for real LCP/INP/CLS to prioritize #012–#016 by measured impact rather than reasoning. | session 2026-07-21 (measurement pass) | 2026-07-21 | +| #018 | P2 | task | Split the lithium, ADHD and metabolic residuals by mechanism | Revalidated on current main 2026-07-23: these are not one composer defect. Lithium reproduced an unrelated-table retrieval fast-path defect; ADHD retrieves a relevant chart-heavy CAMHS source but exhausts the extractive route budget; metabolic retrieves the correct AKG source but selects schedule-free prose. The narrow lithium subject-evidence guard improved targeting from 0 to 1 with golden recall 1.0 and no reciprocal-rank regressions, but it was reverted because the full canary failed. After #051 stabilises the canary, add independent current-main reproducers and assess each mechanism separately. Do not widen the matcher or combine these into a broad ranking/composer change. | runs `30007833352` and `30009207429`; PR #1093; session 2026-07-23 | 2026-07-21 | +| #019 | P2 | task | Admission doc dropped after deterministic comparison packing | Reconfirmed on merged-main run `30018289898`: golden retrieval remained 36/36 and retrieved `MHSP.AdmissionCommunityPts.pdf`, but the answer's top five sources retained only `MHSP.Discharge.pdf` after `generation_fallback:generation_quality_failed; comparison_source_extractive_fallback`. PR #1096 replays the live score/order shape and proves deterministic answer ranking plus cross-document packing retain both admission and discharge evidence, so retrieval scores, aliases and comparator ordering are not the fix. Next: create a red fallback-layer unit reproducer using the live source shape; any behavior change still needs the existing baseline and a passing post canary. | run `30018289898`; PR #1096; session 2026-07-23 | 2026-07-21 | +| #021 | P3 | rec | E-3d H2 residual: strong/comparison generation discards | approx. 6 generation attempts per full 44-case run still fail the final quality gate and fall to extractive on strong-route comparison/complex shapes (the designed-conservative outcome). PARKED: weakest cost/benefit on the queue — a wave (approx. $2-4 pair + reviewer cycle) to shave seconds off a few hard cases. Revisit only if latency/waste complaints or a cheaper lever appears. | E-3c design record; runs #59-#61 diagnostics | 2026-07-21 | +| #022 | P2 | task | Source-governance metadata refresh (operator) | **Worklist generated 2026-07-22 ($0, read-only): `docs/source-governance-refresh-worklist-2026-07-22.md`.** Reframed - this is NOT 59 clinical reviews. Of the 124 documents surfacing in canary top results, 59 are review-required, and **38 (64 pct) are the BMJ published-reference tier all sitting at `clinical_validation_status: unverified`** - one attestation-policy decision, not 38 reviews. The remaining 21 are genuine local WA health-service reviews (FSH 7, NMHS 4, CAMHS 3, AKG 2, KEMH 2, RPBG 2, RKPG 1), mostly `document_status: review_due`. Burn-down: top-10 documents clear 44 pct of flagged slots, top-20 clear 66 pct. Next: decide the BMJ attestation policy, then attest local docs by visibility (start `Clozapine Management by GP (NMHS)`, 22 slots at rank 1). | runs #61/#57 Source Governance data; `docs/source-governance-refresh-worklist-2026-07-22.md` | 2026-07-21 | +| #023 | P2 | task | Read Sunday 2026-07-26 scheduled-run artifacts | The 18:00 UTC scheduled runs deliver three free datapoints at once: first full-44 weekly canary (validates the #1044 ANSWER_CASE_LIMIT raise), browser-matrix flake second datapoint (webkit ui-route-coverage now reproduced + root-caused 2026-07-22 → see #024; firefox ui-formulation:91 still awaits a datapoint), and the irrelevant@10 labeling-audit artifact (§3.1 human-decision class). Read all three, then disposition. | sessions 2026-07-20/21; branch-review-ledger convergence notes | 2026-07-21 | +| #024 | P3 | issue | WebKit e2e `_rsc`-prefetch access-control-checks errors | verify:release:offline on `main` ce32fe170 (2026-07-22) reproduced #023's webkit clause: **6/6 deterministic** failures in `tests/ui-route-coverage.spec.ts` (Therapy Compass; DSM home/comparison; Specifier comparison/map; Differential stream), each a `pageerror … ?_rsc=… due to access control checks` on Next.js RSC prefetch — Chromium + Firefox clean. Not merge-blocking (required gate `test:e2e:pr` is chromium-only; the full webkit matrix is advisory/release-time). Most likely a Playwright route-interception × WebKit interaction, not a Safari user defect. Next: decide (a) allow/mock the `_rsc` routes for the `webkit` e2e project, or (b) confirm real Safari impact — before trusting the full-matrix webkit gate at release. NB the 2 other webkit fails (`ui-stress:412`, `ui-universal-search:210`) passed on isolated re-run = true flake. | session 2026-07-22 (verify:release:offline, `main` ce32fe170); refines #023 | 2026-07-22 | +| #025 | P2 | task | Activate the three webhooks (operator secrets) | Merged (#968/#1100) + deployed but inert — verified live: `POST /api/webhooks/railway` returns `503 webhook_not_configured`; the Supabase document-change trigger exists but lacks both activation inputs. To turn on: (1) Railway → set `RAILWAY_WEBHOOK_SECRET` + add the `?token=…` webhook URL; (2) set `SLACK_WEBHOOK_URL`/`DISCORD_WEBHOOK_URL` in BOTH the Railway **app/server env** and **GitHub repo secrets**; (3) set one matching document-change secret in the Railway app env as `SUPABASE_INGESTION_WEBHOOK_SECRET` and in Supabase Vault as `ingestion_webhook_secret`, then set the per-environment database GUC `app.ingestion_webhook_base_url` to the deployed app origin. Each path fails closed until fully configured, so this is pure ops. See `docs/webhooks.md` for verification and rotation. | sessions 2026-07-22/24; PRs #968/#1100; docs/webhooks.md | 2026-07-22 | +| #027 | P3 | rec | External uptime monitor independent of GitHub/Railway | `live-domain-monitor.yml` runs on GitHub's cron, so it won't run in exactly the outage it should catch (Actions or the deploy itself down). Add an off-platform synthetic monitor (UptimeRobot / Better Stack / Checkly) hitting `/api/health` with a webhook alert. Provider setup, not code. | session 2026-07-22 webhook review | 2026-07-22 | +| #028 | P3 | rec | Runtime error tracking (Sentry or similar) | No error tracking in the repo — production exceptions on `psychiatry.tools`, including how often `RAG_PROVIDER_MODE=auto` silently degrades to source-only, are invisible. Weigh adding `@sentry/nextjs` (dependency + DSN secret + instrumentation) vs cost; alert → chat/issue. Provider-backed; needs explicit sign-off before adding the dependency. | session 2026-07-22 webhook review | 2026-07-22 | +| #029 | P2 | issue | 12 of 30 answer-quality cases return the fallback stub | run #61 --dump-answers: 12/30 quality cases emit the source_backed_review_fallback boilerplate with answer_sections: [], all grounded with 4-6 citations. Some still PASS targeting because the stub echoes query keywords (the contraindication/document_lookup matchers need only a keyword), so the targeting metric MASKS the problem for those intents. Superset of #018 — fix in the extractive composer, validate with the provider-backed answer eval. | run #61 dump artifact; session 2026-07-22 | 2026-07-22 | +| #030 | P2 | issue | Wide-tier alias lets one doc satisfy both comparison slots | In src/lib/eval-document-matching.ts, "Admission to Discharge for Mental Health Inpatients" appears in BOTH the AdmissionCommunityPts and Discharge alias lists, so a single document can satisfy both expectedFiles slots and make allHit true — a latent false-pass on admission-discharge cases. Not firing today (that doc is not in the failing top-5) but it would mask a real miss. Tighten the tables so one doc cannot fill both sides. | src/lib/eval-document-matching.ts:32-65; session 2026-07-22 | 2026-07-22 | +| #032 | P3 | rec | Governance ranking weighting: REFUTED, not debt | The source-governance audit (PR #1051) flagged three "gaps": `review_due` carries no ranking penalty, `unknownCurrentnessPenalty` ships at 0, and `selectBestSourceRecommendation` ignores governance metadata. **These are deliberate, measured decisions — do NOT implement them as written.** Blanket metadata boosts/penalties in selection ordering were measured on 2026-07-02 to regress the golden retrieval eval to 16/23 (doc-recall@5 1.0→0.76, mrr 0.75→0.64). Two corpus facts make it unsafe: scores saturate at the clamp so stacked boosts fully override lexical relevance, and the corpus is only partially metadata-enriched while `normalizeSourceMetadata` coerces unenriched docs to `unknown`/`unverified` — so "unknown" ≠ "bad" and blanket weighting swings ranking approx. 0.35 for reasons unrelated to relevance. Even governance-as-tiebreak buried correct unenriched docs (3 designs bisected). Next action: none — treat as a guardrail. If ever revisited, RC8 (source-strength as a _filter_) is the tracked path, gated on `eval:retrieval:quality` 36/36 plus a live canary pair. | PR #118; `docs/rag-behaviour/refuted-approaches.md`; PR #1051 items 4/5/6 | 2026-07-22 | +| #033 | P3 | rec | Source governance metadata absent from the LLM prompt | `buildRagSourceBlock` omits `document_status`, `clinical_validation_status`, and `extraction_quality`, so the model cannot self-caveat during generation and governance is enforced only post-hoc. Generation-surface change: needs `eval:rag` plus `eval:quality --rag-only` (grounded-supported must not drop, citation-failure 0) and explicit approval. Carries the same "unknown ≠ bad" hazard as #032 — on a partially-enriched corpus the model would likely over-caveat correct sources, so design the prompt wording before spending an eval. | `src/lib/rag/rag-source-block.ts:126-198`; PR #1051 audit item 8 | 2026-07-22 | +| #035 | P3 | rec | Threshold-conflict detection covers only 3 params | `detectThresholdDisagreements` checks only ANC, WBC, and platelets paired with withholding verbs, so cross-source conflicts on medication doses, lithium/thyroid levels, or vital signs go undetected. Deliberately narrow (see the comment at `:469-474`). Broadening changes when an answer is classified `conflicting` and adds warnings — real false-positive risk. Needs new fixtures plus a behaviour review before any change. | `src/lib/evidence.ts:469-574`; PR #1051 audit item 7 | 2026-07-22 | +| #036 | P3 | rec | No explicit `is_public` visibility flag on documents | Public-corpus visibility is implicit: `owner_id IS NULL` on an `indexed` document (`resolveSearchScope`). The `metadata.public_corpus` marker is written by the promotion migrations but never used as a retrieval filter. Promotion is unconditional on `clinical_validation_status`, so unverified documents are publicly searchable — compensated by keeping `unverified_source` in the frontend-visible warning set. A hard schema flag touches RLS and the clinical-risk-gated retrieval RPCs; weigh against the existing compensating control before acting. | `supabase/schema.sql:61-108`; `src/lib/search-scope.ts:181-236`; PR #1051 audit item 3 | 2026-07-22 | +| #037 | P3 | rec | D5 trust-cap-all-claims flag parked OFF | `NEXT_PUBLIC_RAG_TRUST_CAP_ALL_CLAIMS` extends authority gating from high-risk claims to **all** supported claims (`deriveTrust`). Ships OFF by design; flipping it caps trust to `medium` for routine claims across the board — a product/clinical-UX decision, not a defect. Both states are test-pinned. Next action: product decision, then flip and re-baseline the UI expectations. | `src/lib/answer-render-policy.ts:159-177`; PR #1051 audit item 11 | 2026-07-22 | +| #038 | P3 | rec | Consolidate shared comparison behavior | Several clinical modes expose comparison workflows with similar selection, empty-state and mobile-dock needs. Define one shared behavioral contract before another comparison surface is added; keep mode-specific clinical content separate. This is a design-system recommendation, not a current defect. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #039 | P3 | rec | Consolidate catalogue toolbar patterns | Catalogue/search pages have independently evolved filter, sort, result-count and mobile toolbar behavior. Inventory the existing implementations and converge only the repeated interaction contract; do not flatten mode-specific search semantics. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #040 | P3 | rec | Add targeted visual-regression baselines | Keep a small approved baseline set for high-value desktop/mobile surfaces and accessibility modes instead of screenshotting every route. Start with account/settings, document viewer, mode homes and bottom-composer interactions; define an intentional-update workflow before enabling blocking comparisons. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +| #041 | P3 | rec | Extend the existing Factsheets reading model | Do not add a second patient-facing Factsheets mode. Future patient-content work should extend the existing Easy Read/Standard presentation and its accessibility/content contracts. Revisit only with a concrete user need and source-governance plan. | design audit reconciliation; session 2026-07-22 | 2026-07-22 | +>>>>>>> theirs ## Resolved / archive @@ -144,6 +232,14 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +<<<<<<< ours +======= +| #068 | task | Rework the unaccepted structured extractive fallback patch | Reverted the unaccepted `shouldPreserveStructuredExtractiveFallback` behavior and restored the generic source-backed review fallback routing/tests rather than layering more preservation logic on top. Focused RAG fallback tests pass. | 2026-07-24 | +| #061 | issue | Missing answer relevance metadata is treated as source-backed | `deriveTrust` now treats missing `EvidenceRelevance` as not source-backed, capping the render model to low trust while explicit direct/partial source-backed relevance preserves existing behavior. Added render-policy coverage for absent relevance metadata. | 2026-07-24 | +| #052 | issue | Reindex can overlap a fresh agent-enrichment pass | Full/retry single and bulk reindex safety checks now optionally query fresh `indexing_v3_agent_jobs.status=processing` leases and return a 409 `active_agent_enrichment` result before mutating queue state. Enrichment-mode RPC calls remain unchanged. | 2026-07-24 | +| #062 | issue | Upload crash can strand a queued document without a job | Added service-role RPC `create_uploaded_document_with_ingestion_job(jsonb, integer)` and routed uploads through it so document row creation and initial ingestion job creation commit atomically. Upload cleanup still removes storage and the committed document on post-enqueue abort/failure. | 2026-07-24 | +| #060 | issue | Safety Plan Generator contradicted the privacy contract | PR #1119 removed patient identifier entry, leaves the post-export name line blank, and aligned tool, privacy and PIA copy. DOM/privacy tests and Chromium copy/print/network coverage prove working content remains in React memory with no fetch/XHR; hosted Production UI, build, unit, policy, safety, static-analysis and secret checks passed. Support-contact details remain classified as sensitive local-only working content. | 2026-07-24 | +>>>>>>> theirs | #034 | issue | Answer cache can serve stale governance metadata | Current-source verification found direct route coverage already asserts RAG-cache invalidation on document PATCH, source review, label, bulk, and reindex mutation paths. The residual test recommendation is already met; changing the protected cache key is unnecessary. | 2026-07-24 | | #014 | rec | Realize the `next/image` win on signed previews | Superseded: `SignedImage` uses `next/image` for layout and sizing but deliberately sets `unoptimized`, preventing bearer signed URLs from entering the unauthenticated optimizer cache where cached content could outlive the token. No optimization task remains unless private-image delivery changes. | 2026-07-24 | | #026 | task | Wire the Supabase document-change trigger | PR #1100 merged after disposable PostgreSQL replay and hosted migration replay. Production migration history and read-only catalog proof confirm the enabled metadata trigger, security-definer function, pinned search path and denied anonymous/authenticated execution; `npm run check:drift` reports no unexpected live drift. Delivery remains intentionally inert until the operator inputs tracked in #025 are configured. | 2026-07-24 | diff --git a/docs/process-hardening.md b/docs/process-hardening.md index f6aac9a01..d6d1d8b46 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -252,7 +252,7 @@ passes `p_worker_id`. Ordered apply steps, R17 manual `CONCURRENTLY` index, and - **Eval debt (blocking merge, not development):** `npm run eval:retrieval:quality` (23/23) and `eval:quality --rag-only` (`unsupported_correct_rate` 1.0) could NOT be run in the authoring environment (no live keys) — they MUST be run before merge per the standing gate above, with special attention to the weak-match OR-augmentation (flag off restores relax-on-empty exactly) and the retrieval-selection tiebreak (tie-only by construction). - **Eval debt settled 2026-07-07 — both flagged changes had regressed the golden eval (31/34 on main).** Isolated case-by-case against the same live corpus (pre-#325 code passed all 3): the weak-match OR-augmentation buried `opioid-withdrawal-doses` (docRecall@5 → 0; OR recall is append-only at the RPC merge but NOT after re-ranking), and the `selectRetrievalEvidence` pre-clamp tiebreak buried `alcohol-ciwa-threshold` + `clozapine-cbc-abbreviation-threshold` (boost-stacking magnitude re-ordered saturated top-5 sets). Fixed on `claude/retrieval-correctness`: `RAG_TEXT_WEAK_OR_RELAXATION` now defaults to `false` (opt-in experiment flag; re-enable only behind a fresh full golden run) and the selection-layer tiebreak is removed — the pre-clamp deep tiebreak lives in `rankClinicalResults` (below the engineered `rankingTieBreakScore`), which is the only place it is eval-proven. Lesson reinforced: "tie-only by construction" still changes which tied candidate wins; ordering changes inside saturated score regions are behavior changes and need the golden gate. - **UI verification run:** new `tests/ui-universal-search.spec.ts` (grouped typeahead renders, item selection navigates, Enter still runs the mode search — universal endpoint mocked), full `ui-tools`/`ui-tools-task-directory` (40/40) and `ui-smoke`/`ui-overlap` suites against a live dev server in demo mode, plus a live curl of `/api/search/universal` (grouped payload, domain filter, 400 on short query). -- **Known limitation:** the typeahead spec mocks the universal endpoint; an end-to-end spec against live seeded registries needs the owner-auth Playwright project (E2E_USER_* keys). +- **Known limitation:** the typeahead spec mocks the universal endpoint; an end-to-end spec against live seeded registries needs the owner-auth Playwright project (E2E*USER*\* keys). ## Cross-mode answer links workstream — verification state (2026-07-06) diff --git a/docs/search-chrome-behaviour.md b/docs/search-chrome-behaviour.md new file mode 100644 index 000000000..a3f9236ed --- /dev/null +++ b/docs/search-chrome-behaviour.md @@ -0,0 +1,35 @@ +# Search chrome behaviour contract + +This repo uses one shared search experience across the global shell, dashboard result pages, and document-detail/source routes. Keep the behaviour page-aware but predictable. + +## Page ownership model + +| Page state | Composer placement | Reserve owner | +| --- | --- | --- | +| Answer home / standalone mode homes | In-flow hero composer on phones and larger breakpoints | Page content; no fixed phone dock reserve | +| Submitted/search-result views | Compact bottom dock on phones; header/inline placement on larger screens | Shell/dashboard `--mobile-composer-reserve` | +| Answer result view | Overlaid glass header plus answer composer dock | Dashboard `#main-content` top/bottom reserves | +| Document detail/source routes | `DocumentViewer` floating composer | `DocumentViewer` content padding | +| Info/detail pages with no composer | No fixed composer | Idle shell padding only | + +## Invariants + +1. Use `src/components/clinical-dashboard/mobile-composer-reserve.ts` as the TypeScript source of truth for phone composer clearances. +2. Keep the CSS token `--phone-dock-hidden-pad` aligned with `mobileComposerHiddenReserve`. +3. A visible fixed phone dock may include `var(--safe-area-bottom)` so the pill clears the home indicator. +4. A hidden phone dock must release the content-facing reserve to `0rem`; do not use `env(safe-area-inset-bottom)` or `var(--safe-area-bottom)` for hidden content padding. +5. Edge-to-edge phone dock mode is `left: 0; right: 0; bottom: 0; width: 100%`; inset the pill with padding, not with a non-zero bottom offset. +6. Header and footer chrome that share the same scroll signal should hide/reveal symmetrically: when hidden, underlying content must be visible to the viewport edge. +7. Do not add page-local dock-sized `pb-[calc(...safe-area...)]` under a shell-owned dock. Put clearance in the shared reserve or the page-owned composer, never both. +8. `GlobalSearchShell` uses an inner `mobile-composer-reserve-pad` so phone padding contributes to scroll height; do not move phone shell clearance back to scrollport padding without a browser proof. + +## Change checklist + +Before changing search bar behaviour: + +- Identify the page ownership row above. +- Confirm whether the page is using `GlobalSearchShell`, `ClinicalDashboard`, or `DocumentViewer` for the composer. +- Update the reserve helper and CSS token together when changing clearances. +- Add or update a focused static contract test for new constants or exceptions. +- For visual/scroll changes, run the relevant phone-scroll/overlap Playwright coverage through `npm run ensure` and `npm run verify:ui` when the environment supports the repo runtime. +- If a new route has a page-owned composer, document it here and add it to the route/search coverage rather than relying on comments in a component. diff --git a/package-lock.json b/package-lock.json index 18a6c8139..85e50bddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,7 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@babel/parser": "^8.0.4", - "@next/bundle-analyzer": "^16.2.10", + "@next/bundle-analyzer": "^16.2.11", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", @@ -45,7 +45,7 @@ "@vitest/coverage-v8": "^4.1.10", "esbuild": "0.28.1", "eslint": "^9.39.5", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.11", "fast-check": "^4.8.0", "jsdom": "^29.1.1", "knip": "^6.27.0", @@ -1523,9 +1523,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1542,9 +1539,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1561,9 +1555,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1580,9 +1571,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1599,9 +1587,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1618,9 +1603,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1637,9 +1619,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1656,9 +1635,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1675,9 +1651,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1700,9 +1673,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1725,9 +1695,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1750,9 +1717,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1775,9 +1739,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1800,9 +1761,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1825,9 +1783,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1850,9 +1805,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2234,9 +2186,9 @@ } }, "node_modules/@next/bundle-analyzer": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.10.tgz", - "integrity": "sha512-KcepWhb3IVniZgm00GSSCQDEUQqZXuXtuXRh8J6e3Un342TcQ77iK4DedeEkct+fcx7yFEDL2J6z4Jeho5JDAw==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-16.2.11.tgz", + "integrity": "sha512-C5228wy1RC0g7uOSvmQhrZXCuEeLIPureKkqramAkySmqY2Y0yw6K+TyAxXXvtrYe4uehnZs3YMFfJcorBRwpg==", "dev": true, "license": "MIT", "dependencies": { @@ -2250,9 +2202,9 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.10.tgz", - "integrity": "sha512-Gs8D2m21VnJeFo9qvYIIqJH94frWerWYu41BprU1pLtRVF7PCQNLiFZZ3fG+iPuj3K83Cwv/rt+msLOy8Qgu3Q==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.11.tgz", + "integrity": "sha512-vMEf/aXOpzFFdtIvFYOnIDPKb0xBbrXONsz83CcKdRrekfxNdL8PNkq5qHqAHSXVlIifnX68LOMaxr3z5PkeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3686,9 +3638,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3706,9 +3655,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3726,9 +3672,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3746,9 +3689,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6217,13 +6157,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.10", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.10.tgz", - "integrity": "sha512-HSybLOY0QKf39i4FWUqPN0xWiNDi6A6UqJmZtgDkS3zMqjXTqULvj/sueXx3cdCG0mVG+qH6k5/qdegklH1d1w==", + "version": "16.2.11", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.11.tgz", + "integrity": "sha512-FIpbK/dUyxUExchDB7eBg3k+VU8R2iR/Cx9/kqTBUTFv2bOIR9aRrpno4rvAQ9VhiPQAyFKNA2NlZwouGWtclA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.10", + "@next/eslint-plugin-next": "16.2.11", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", diff --git a/package.json b/package.json index 9335c003a..841ec578e 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts", "verify:cheap": "node scripts/run-heavy.mjs --npm-script verify:cheap:internal", - "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:migration-role && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", + "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run check:branch-review-ledger && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:migration-role && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", "verify:pr-local": "node scripts/verify-pr-local.mjs", "verify:ui": "npm run check:runtime && npm run test:e2e:pr", "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", @@ -58,6 +58,7 @@ "check:ci-scope": "node scripts/ci-change-scope.mjs --self-test", "check:ci-triage": "node scripts/ci-triage.mjs --self-test", "check:gate-manifest": "node scripts/check-gate-manifest.mjs", + "check:branch-review-ledger": "node scripts/check-branch-review-ledger.mjs --self-test && node scripts/check-branch-review-ledger.mjs", "check:pr-policy": "node scripts/pr-policy.mjs --self-test && node scripts/check-pr-policy-workflow.mjs", "check:env-parity": "node scripts/check-env-parity.mjs", "sweep:branch-ledger": "node scripts/sweep-branch-ledger.mjs", @@ -158,6 +159,7 @@ "cleanup:storage": "node scripts/run-tsx.mjs scripts/cleanup-storage.ts", "purge:query-logs": "node scripts/run-tsx.mjs scripts/purge-query-logs.ts", "audit:tables": "node scripts/run-tsx.mjs scripts/audit-tables.ts", + "audit:formatting-fixtures": "node scripts/run-tsx.mjs scripts/audit-formatting-fixtures.ts", "samples": "node scripts/run-tsx.mjs scripts/generate-sample-documents.ts", "samples:check": "node scripts/run-tsx.mjs scripts/check-sample-extraction.ts", "workflow:run": "node scripts/external-workflow.mjs run", @@ -174,6 +176,8 @@ "workflow:rag-lab": "node scripts/productivity-workflow.mjs rag-lab", "workflow:operator-closeout": "node scripts/productivity-workflow.mjs operator-closeout", "workflow:lifecycle": "node scripts/productivity-workflow.mjs lifecycle", + "skill:create": "node scripts/skill-create.mjs", + "branch:cleanup": "node scripts/sweep-merged-branches.mjs", "skills": "node scripts/list-database-skills.mjs", "check:skills": "node scripts/list-database-skills.mjs --check", "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", @@ -211,7 +215,7 @@ "devDependencies": { "@axe-core/playwright": "^4.12.1", "@babel/parser": "^8.0.4", - "@next/bundle-analyzer": "^16.2.10", + "@next/bundle-analyzer": "^16.2.11", "@tailwindcss/postcss": "^4.3.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^7.0.0", @@ -225,7 +229,7 @@ "@vitest/coverage-v8": "^4.1.10", "esbuild": "0.28.1", "eslint": "^9.39.5", - "eslint-config-next": "16.2.10", + "eslint-config-next": "16.2.11", "fast-check": "^4.8.0", "jsdom": "^29.1.1", "knip": "^6.27.0", diff --git a/playwright.config.ts b/playwright.config.ts index 87e81e8a4..897d06694 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -13,14 +13,14 @@ const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; // they share a spec file. Every required browser project uses the same // production matcher and tag exclusion. const productionSpecPattern = - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|phone-scroll|pwa|route-coverage|visual-artifacts))\.spec\.ts/; + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|overlap|universal-search|specifiers|formulation|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/; const mockupSpecPattern = /.*ui-(tools|tools-collapse|tools-task-directory)\.spec\.ts/; const mockupTag = /@mockup/; export default defineConfig({ testDir: "./tests", testMatch: - /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|phone-scroll|pwa|route-coverage|visual-artifacts))\.spec\.ts/, + /.*(?:answer-progress-ui-smoke|ui-(smoke|stress|accessibility|tools|tools-collapse|tools-task-directory|overlap|universal-search|specifiers|formulation|phone-scroll|pwa|route-coverage|visual-artifacts|hydration))\.spec\.ts/, timeout: 60_000, retries: 0, // Fail the run if a stray `test.only` is committed: otherwise it silently diff --git a/scripts/audit-formatting-fixtures.ts b/scripts/audit-formatting-fixtures.ts new file mode 100644 index 000000000..df833a0c0 --- /dev/null +++ b/scripts/audit-formatting-fixtures.ts @@ -0,0 +1,99 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +type FormattingIssue = { + file: string; + issue: string; + severity: "warning" | "fail"; +}; + +function numberValue(value: unknown) { + const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + return Number.isFinite(number) ? number : null; +} + +function metadata(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +async function auditExtractJson(file: string): Promise { + const raw = await readFile(file, "utf8"); + const payload = JSON.parse(raw) as { + images?: Array>; + pages?: Array>; + }; + const issues: FormattingIssue[] = []; + const images = payload.images ?? []; + const pages = payload.pages ?? []; + + for (const [index, image] of images.entries()) { + const meta = metadata(image.metadata); + const label = `${file}#image-${index + 1}`; + const sourceKind = String(image.sourceKind ?? image.source_kind ?? ""); + const width = numberValue(image.width); + const height = numberValue(image.height); + const cropCompleteness = numberValue(meta.crop_completeness); + const structuredConfidence = numberValue(meta.structured_extraction_confidence); + const ocrDensity = numberValue(meta.ocr_text_density); + + if (sourceKind === "table_crop" && !Array.isArray(meta.table_rows)) { + issues.push({ file: label, severity: "fail", issue: "table crop missing structured table rows" }); + } + if (meta.rows_truncated === true) { + issues.push({ file: label, severity: "warning", issue: "table rows truncated" }); + } + if (cropCompleteness !== null && cropCompleteness < 0.82) { + issues.push({ file: label, severity: "warning", issue: `crop cut-off risk (${cropCompleteness})` }); + } + if (structuredConfidence !== null && structuredConfidence < 0.58) { + issues.push({ file: label, severity: "warning", issue: `low structured confidence (${structuredConfidence})` }); + } + if (ocrDensity !== null && ocrDensity < 0.18) { + issues.push({ file: label, severity: "warning", issue: `low OCR text density (${ocrDensity})` }); + } + if (width && height) { + const ratio = width / height; + if (ratio > 4 || ratio < 0.45) { + issues.push({ file: label, severity: "warning", issue: `extreme image aspect ratio (${ratio.toFixed(2)})` }); + } + } + } + + for (const page of pages) { + if (page.needsOcr === true) { + issues.push({ + file: `${file}#page-${String(page.pageNumber ?? "?")}`, + severity: "fail", + issue: "page needs OCR but OCR text was unavailable", + }); + } + } + + return issues; +} + +async function main() { + const files = process.argv.slice(2); + if (files.length === 0) { + console.log("Usage: npm run audit:formatting-fixtures -- artifacts/extract.json [...]"); + return; + } + const issues: FormattingIssue[] = []; + for (const file of files) { + const absolute = path.resolve(file); + await stat(absolute); + issues.push(...(await auditExtractJson(absolute))); + } + console.log(`Formatting fixture audit: ${files.length} file(s), ${issues.length} issue(s)`); + for (const issue of issues) { + console.log(`${issue.severity.toUpperCase()} ${issue.file}: ${issue.issue}`); + } + if (issues.some((issue) => issue.severity === "fail")) { + throw new Error("Formatting fixture audit found failing issues."); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/check-branch-review-ledger.mjs b/scripts/check-branch-review-ledger.mjs new file mode 100644 index 000000000..7fc8cc126 --- /dev/null +++ b/scripts/check-branch-review-ledger.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Prevent the append-only branch review ledger from becoming a recurring merge + * hazard. The union driver preserves concurrent appends; this gate catches a + * missing attribute, accidentally committed conflict markers, and exact duplicate + * records before they can land on the shared branch. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const LEDGER_PATH = "docs/branch-review-ledger.md"; +const PROTOCOL_PATH = "docs/codex-review-protocol.md"; + +const conflictMarker = /^(?:<{7}(?: .*)?|={7}|>{7}(?: .*)?)\r?$/gm; +const datedTableRow = /^\| \d{4}-\d{2}-\d{2} \|/; + +export function validateLedger({ ledger, mergeAttribute, protocol }) { + const failures = []; + + if (mergeAttribute !== "union") { + failures.push(`${LEDGER_PATH} must resolve to merge=union (found ${JSON.stringify(mergeAttribute || "unset")}).`); + } + + const markers = [...ledger.matchAll(conflictMarker)]; + if (markers.length > 0) { + const lines = markers.map((match) => ledger.slice(0, match.index).split(/\r?\n/).length); + failures.push(`conflict marker(s) found at ledger line(s): ${lines.join(", ")}.`); + } + + const rows = ledger.split(/\r?\n/).filter((line) => datedTableRow.test(line)); + const seen = new Set(); + const duplicates = []; + for (const row of rows) { + if (seen.has(row)) duplicates.push(row); + seen.add(row); + } + if (duplicates.length > 0) { + failures.push(`${duplicates.length} exact duplicate review record(s) found.`); + } + + if (!ledger.includes("This file is append-only.")) { + failures.push(`${LEDGER_PATH} is missing its append-only editing contract.`); + } + if (!protocol.includes("The ledger is append-only:")) { + failures.push(`${PROTOCOL_PATH} is missing its append-only reviewer instruction.`); + } + + return { failures, recordCount: rows.length }; +} + +function effectiveMergeAttribute() { + const output = execFileSync("git", ["check-attr", "merge", "--", LEDGER_PATH], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + return output.match(/:\s*merge:\s*(\S+)$/)?.[1] ?? ""; +} + +function assert(condition, label) { + if (!condition) throw new Error(`self-test failed: ${label}`); +} + +function selfTest() { + const valid = { + ledger: "# Ledger\n\nThis file is append-only.\n| 2026-07-24 | branch | head | scope | outcome | checks |\n", + mergeAttribute: "union", + protocol: "The ledger is append-only: append corrections.", + }; + + assert(validateLedger(valid).failures.length === 0, "valid ledger passes"); + assert( + validateLedger({ ...valid, mergeAttribute: "" }).failures.some((failure) => failure.includes("merge=union")), + "missing union attribute fails", + ); + assert( + validateLedger({ ...valid, ledger: `${valid.ledger}<<<<<<< ours\n` }).failures.some((failure) => + failure.includes("conflict marker"), + ), + "conflict marker fails", + ); + const row = "| 2026-07-24 | branch | head | scope | outcome | checks |"; + assert( + validateLedger({ ...valid, ledger: `This file is append-only.\n${row}\n${row}\n` }).failures.some((failure) => + failure.includes("duplicate"), + ), + "exact duplicate record fails", + ); + assert( + validateLedger({ ...valid, protocol: "append records" }).failures.some((failure) => + failure.includes("reviewer instruction"), + ), + "missing protocol contract fails", + ); + + console.log("branch-review-ledger self-test passed."); +} + +function main() { + if (process.argv.includes("--self-test")) { + selfTest(); + return; + } + + const result = validateLedger({ + ledger: readFileSync(path.join(root, LEDGER_PATH), "utf8"), + mergeAttribute: effectiveMergeAttribute(), + protocol: readFileSync(path.join(root, PROTOCOL_PATH), "utf8"), + }); + + if (result.failures.length > 0) { + console.error("Branch review ledger guard failed:"); + for (const failure of result.failures) console.error(`- ${failure}`); + process.exit(1); + } + + console.log( + `Branch review ledger guard passed: ${result.recordCount} table records, union merge active, no conflict markers or exact duplicates.`, + ); +} + +main(); diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 6f167b95b..9db2b94a1 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; import path from "node:path"; import { validateActionReference } from "./github-action-pins.mjs"; import { yamlBlock } from "./yaml-contract.mjs"; @@ -10,30 +11,92 @@ const failures = []; const expectedSupabaseCliVersion = "2.108.0"; const expectedSupabaseCliVersionPattern = expectedSupabaseCliVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -function discoverGitHubActionFiles(workflowRoot) { - const workflowDir = path.join(workflowRoot, ".github", "workflows"); +function discoverWorkflowFiles(root) { + const workflowDir = path.join(root, ".github", "workflows"); if (!existsSync(workflowDir)) return []; return readdirSync(workflowDir, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name)) .map((entry) => path.join(workflowDir, entry.name)); } -for (const filePath of discoverGitHubActionFiles(process.cwd())) { - const fileName = path.relative(process.cwd(), filePath).replaceAll("\\", "/"); - const lines = readFileSync(filePath, "utf8").split(/\r?\n/); +function discoverCompositeActionFiles(root) { + const actionsDir = path.join(root, ".github", "actions"); + if (!existsSync(actionsDir)) return []; + const files = []; + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + visit(fullPath); + } else if (/^action\.ya?ml$/i.test(entry.name)) { + files.push(fullPath); + } + } + }; + visit(actionsDir); + return files; +} + +function discoverGitHubActionFiles(root) { + return [...discoverWorkflowFiles(root), ...discoverCompositeActionFiles(root)]; +} + +function collectPinFailures(root) { + const failures = []; + for (const filePath of discoverGitHubActionFiles(root)) { + const fileName = path.relative(root, filePath).replaceAll("\\", "/"); + const lines = readFileSync(filePath, "utf8").split(/\r?\n/); + + lines.forEach((line, index) => { + if (runsOnLatestPattern.test(line)) { + failures.push( + `${fileName}:${index + 1}: runs-on uses ubuntu-latest. Pin GitHub-hosted Linux jobs to ubuntu-24.04 so CI is not tied to the moving ubuntu-latest alias.`, + ); + } + + const actionFailure = validateActionReference(line); + if (actionFailure) failures.push(`${fileName}:${index + 1}: ${actionFailure}`); + }); + } + return failures; +} - lines.forEach((line, index) => { - if (runsOnLatestPattern.test(line)) { - failures.push( - `${fileName}:${index + 1}: runs-on uses ubuntu-latest. Pin GitHub-hosted Linux jobs to ubuntu-24.04 so CI is not tied to the moving ubuntu-latest alias.`, - ); +function selfTest() { + const root = mkdtempSync(path.join(os.tmpdir(), "github-action-pin-check-")); + try { + const workflowDir = path.join(root, ".github", "workflows"); + const actionDir = path.join(root, ".github", "actions", "fixture"); + mkdirSync(workflowDir, { recursive: true }); + mkdirSync(actionDir, { recursive: true }); + writeFileSync(path.join(workflowDir, "ok.yml"), "name: ok\n", "utf8"); + writeFileSync( + path.join(actionDir, "action.yml"), + "name: fixture\nruns:\n using: composite\n steps:\n - uses: actions/cache@v6\n", + "utf8", + ); + + const failures = collectPinFailures(root); + if ( + !failures.some( + (failure) => failure.includes(".github/actions/fixture/action.yml") && failure.includes("actions/cache@v6"), + ) + ) { + throw new Error("self-test failed: composite action uses entries were not scanned"); } + } finally { + rmSync(root, { recursive: true, force: true }); + } +} - const actionFailure = validateActionReference(line); - if (actionFailure) failures.push(`${fileName}:${index + 1}: ${actionFailure}`); - }); +if (process.argv.includes("--self-test")) { + selfTest(); + console.log("GitHub Actions pin check self-test passed."); + process.exit(0); } +selfTest(); +failures.push(...collectPinFailures(process.cwd())); + const ciWorkflowPath = path.join(workflowDir, "ci.yml"); const ciWorkflow = readFileSync(ciWorkflowPath, "utf8"); const migrationJob = yamlBlock(ciWorkflow, "db-reset-verify:", 2); @@ -110,19 +173,6 @@ if (!/^ image: semgrep\/semgrep@sha256:[0-9a-f]{64}\s*$/m.test(semgrepGateJ // the per-line validation above only covers workflows, a composite skew (e.g. // setup-node v5 vs v7) was previously invisible. Assert each action name resolves // to a single SHA everywhere it is used. -function discoverCompositeActionFiles(workflowRoot) { - const actionsRoot = path.join(workflowRoot, ".github", "actions"); - if (!existsSync(actionsRoot)) return []; - const files = []; - for (const entry of readdirSync(actionsRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - for (const name of ["action.yml", "action.yaml"]) { - const candidate = path.join(actionsRoot, entry.name, name); - if (existsSync(candidate)) files.push(candidate); - } - } - return files; -} const actionPinPattern = /uses:\s*([^@\s]+)@([0-9a-f]{40})(?:\s*#\s*(\S+))?/; const shasByAction = new Map(); diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts index 7a048b6a4..a720deb65 100644 --- a/scripts/eval-retrieval.ts +++ b/scripts/eval-retrieval.ts @@ -912,8 +912,24 @@ async function main() { for (const warning of readinessWarnings) console.warn(`WARN ${warning}`); } - for (let caseIndex = 0; caseIndex < cases.length; caseIndex += 1) { - const testCase = cases[caseIndex]!; + function pLimit(concurrency: number) { + let active = 0; + const queue: Array<() => void> = []; + return async (fn: () => Promise): Promise => { + if (active >= concurrency) await new Promise((resolve) => queue.push(resolve)); + active++; + try { return await fn(); } + finally { + active--; + if (queue.length > 0) queue.shift()!(); + } + }; + } + + const concurrency = args.mode === "latency" ? 1 : 5; + const limitEvaluations = pLimit(concurrency); + + const results: GoldenRetrievalResult[] = await Promise.all(cases.map((testCase, caseIndex) => limitEvaluations(async () => { await pauseBetweenEvalCases({ caseIndex, forceEmbedding: testCase.forceEmbedding || args.forceEmbedding, @@ -960,7 +976,6 @@ async function main() { latencyFailures, globalForceEmbedding: args.forceEmbedding, }); - results.push(result); if (!args.json) { const status = @@ -975,7 +990,8 @@ async function main() { `${status} ${result.id} hit@${result.topK}=${result.hitAtK ? "1" : "0"} docRecall@5=${result.documentRecallAt5.toFixed(2)} contentRecall@5=${result.contentRecallAt5.toFixed(2)} rr@10=${result.reciprocalRankAt10.toFixed(2)} contentRR@10=${result.contentReciprocalRankAt10.toFixed(2)} ndcg@10=${result.ndcgAt10.toFixed(2)} irrelevant@10=${result.irrelevantSourceRateAt10.toFixed(2)} signalCoverage@10=${result.requiredSignalCoverageAt10.toFixed(2)} latency=${result.latencyMs}ms strategy=${result.retrievalStrategy ?? "none"} gate=${result.coverageGateReason ?? "none"} layers=${JSON.stringify(result.retrievalLayerCounts ?? {})}`, ); } - } + return result; + }))); const summary = summarizeGoldenRetrievalResults(results); const latencyThresholdFailures = diff --git a/scripts/run-heavy.mjs b/scripts/run-heavy.mjs index 5e7b038d4..c332613ef 100644 --- a/scripts/run-heavy.mjs +++ b/scripts/run-heavy.mjs @@ -13,8 +13,10 @@ if (args[0] !== "--npm-script" || !args[1]) { } const script = args[1]; -const forwarded = args.slice(2); -const lock = acquireHeavyRunLock({ projectRoot, command: `npm run ${script}` }); +const rawForwarded = args.slice(2); +const forceLockRelease = rawForwarded.includes("--force-lock-release"); +const forwarded = rawForwarded.filter(a => a !== "--force-lock-release"); +const lock = acquireHeavyRunLock({ projectRoot, command: `npm run ${script}`, forceLockRelease }); let exitCode = 1; try { const npmExecPath = process.env.npm_execpath; diff --git a/scripts/skill-create.mjs b/scripts/skill-create.mjs new file mode 100644 index 000000000..d492115d2 --- /dev/null +++ b/scripts/skill-create.mjs @@ -0,0 +1,60 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(scriptDirectory, ".."); +const skillsRoot = path.join(repositoryRoot, ".agents", "skills"); +const catalogPath = path.join(skillsRoot, "catalog.json"); + +function createSkill(skillName) { + if (!/^[a-z0-9-]+$/.test(skillName)) { + console.error(`Invalid skill name: ${skillName}. Only lowercase letters, numbers, and dashes are allowed.`); + process.exit(1); + } + + const skillDir = path.join(skillsRoot, skillName); + if (fs.existsSync(skillDir)) { + console.error(`Skill directory already exists: ${skillDir}`); + process.exit(1); + } + + // Create directories + fs.mkdirSync(path.join(skillDir, "agents"), { recursive: true }); + + // Create SKILL.md + const skillMdContent = `--- +name: ${skillName} +description: Description for ${skillName}. Use this skill to do something. +--- +# ${skillName} + +Detailed instructions for the ${skillName} skill. +`; + fs.writeFileSync(path.join(skillDir, "SKILL.md"), skillMdContent, "utf8"); + + // Create agents/openai.yaml + const openaiYamlContent = `name: ${skillName} +short_description: "Short description for ${skillName} (25-64 chars)." +default_prompt: "Execute $${skillName} for this task." +`; + fs.writeFileSync(path.join(skillDir, "agents", "openai.yaml"), openaiYamlContent, "utf8"); + + // Update catalog.json (add to Everyday by default) + const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf8")); + const everydayCategory = catalog.categories.find(c => c.name === "Everyday"); + if (everydayCategory && !everydayCategory.skills.includes(skillName)) { + everydayCategory.skills.push(skillName); + fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2) + "\n", "utf8"); + } + + console.log(`Successfully created skill: ${skillName}`); +} + +const args = process.argv.slice(2); +if (args.length !== 1) { + console.error("Usage: npm run skill:create "); + process.exit(1); +} + +createSkill(args[0]); diff --git a/scripts/sweep-merged-branches.mjs b/scripts/sweep-merged-branches.mjs new file mode 100644 index 000000000..6dcd56e7a --- /dev/null +++ b/scripts/sweep-merged-branches.mjs @@ -0,0 +1,40 @@ +import { execSync } from "node:child_process"; + +const run = (cmd) => execSync(cmd, { encoding: "utf8" }).trim(); + +try { + // Prune remote tracking branches + run("git remote prune origin"); + + // Get current branch + const currentBranch = run("git rev-parse --abbrev-ref HEAD"); + + // Get branches merged into main + const mergedBranches = run("git branch --merged main") + .split("\n") + .map((b) => b.trim().replace(/^\*\s+/, "")) + .filter(Boolean); + + const branchesToDelete = mergedBranches.filter( + (b) => b !== "main" && b !== "master" && b !== currentBranch && !b.startsWith("release/") + ); + + if (branchesToDelete.length === 0) { + console.log("No merged branches to clean up."); + process.exit(0); + } + + for (const branch of branchesToDelete) { + console.log(`Deleting merged branch: ${branch}`); + try { + run(`git branch -d ${branch}`); + } catch (err) { + console.warn(`Failed to delete branch ${branch}: ${err.message}`); + } + } + + console.log("Branch cleanup complete."); +} catch (error) { + console.error("Error cleaning up branches:", error.message); + process.exit(1); +} diff --git a/scripts/test-run-lock.mjs b/scripts/test-run-lock.mjs index db671ff37..0aae451b9 100644 --- a/scripts/test-run-lock.mjs +++ b/scripts/test-run-lock.mjs @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync, utimesSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; @@ -83,6 +83,7 @@ export function acquireHeavyRunLock({ baseDirectory, repositoryIdentity = resolveRepositoryIdentity(projectRoot), processId = process.pid, + forceLockRelease = false, }) { if (!projectRoot) throw new Error("projectRoot is required for the Database heavyweight-run lock."); const lockPath = lockPathFor(repositoryIdentity, baseDirectory); @@ -115,7 +116,18 @@ export function acquireHeavyRunLock({ startedAt: new Date().toISOString(), }; writeFileSync(path.join(lockPath, "owner.json"), `${JSON.stringify(owner, null, 2)}\n`, "utf8"); + let released = false; + const heartbeatInterval = setInterval(() => { + try { + const now = new Date(); + utimesSync(path.join(lockPath, "owner.json"), now, now); + } catch { + // ignore if lock owner file is gone + } + }, 60_000); + heartbeatInterval.unref(); + return { path: lockPath, owner, @@ -128,12 +140,34 @@ export function acquireHeavyRunLock({ release() { if (released) return; released = true; + clearInterval(heartbeatInterval); if (readOwner(lockPath)?.token === token) rmSync(lockPath, { recursive: true, force: true }); }, }; } catch (error) { if (error?.code !== "EEXIST") throw error; const owner = readOwner(lockPath); + + let isStale = false; + if (owner) { + if (!processIsAlive(owner.pid)) { + isStale = true; + } else { + try { + const mtime = statSync(path.join(lockPath, "owner.json")).mtimeMs; + if (Date.now() - mtime > 30 * 60 * 1000) { + isStale = true; + } + } catch {} + } + } + + if (forceLockRelease || isStale) { + console.warn(`[AUDIT] Breaking ${isStale ? "stale" : "forced"} heavyweight lock at ${lockPath} (was PID ${owner?.pid || "unknown"})`); + rmSync(lockPath, { recursive: true, force: true }); + continue; + } + if (owner && processIsAlive(owner.pid)) { throw new Error( `Another Database heavyweight command is active (PID ${owner.pid}, worktree ${owner.worktree ?? "unknown"}, started ${owner.startedAt ?? "unknown"}): ${redactSensitiveText(owner.command ?? "unknown command")}`, diff --git a/src/app/api/answer/route.ts b/src/app/api/answer/route.ts index a5d8fae7c..306a13640 100644 --- a/src/app/api/answer/route.ts +++ b/src/app/api/answer/route.ts @@ -1,13 +1,26 @@ import { randomUUID } from "node:crypto"; import { NextResponse } from "next/server"; import { z } from "zod"; -import { demoAnswer } from "@/lib/demo-data"; +import { demoAnswer, demoSummary } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; +<<<<<<< ours +<<<<<<< ours +<<<<<<< ours import { answerQuestionWithScope } from "@/lib/rag/rag"; +======= +import { answerQuestionWithScope, summarizeDocument } from "@/lib/rag"; +>>>>>>> theirs +======= +import { answerQuestionWithScope, summarizeDocument } from "@/lib/rag"; +>>>>>>> theirs +======= +import { answerQuestionWithScope, summarizeDocument } from "@/lib/rag"; +>>>>>>> theirs import { jsonError, PublicApiError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, + consumeSummaryRateLimits, rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -30,6 +43,7 @@ import { nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import * as serverAuth from "@/lib/supabase/auth"; import { answerRequestSchema, type AnswerRequestBody } from "@/lib/validation/answer-request"; import { answerFeedbackMetadata } from "@/lib/answer-feedback-token"; +import { documentSummaryQuestion } from "@/lib/answer-contract"; export const runtime = "nodejs"; @@ -37,7 +51,10 @@ const emptyScopeAnswer = "The selected filters did not match any indexed documents, so I cannot generate an answer for that scope."; function buildDemoAnswerPayload(body: AnswerRequestBody, fallbackReason?: string) { - const answer = demoAnswer(body.query, body.documentId, body.documentIds); + const answer = + body.summaryMode && body.documentId + ? demoSummary(body.documentId) + : demoAnswer(body.query, body.documentId, body.documentIds); const answerFocusQuery = queryForClinicalMode(body.query, body.queryMode); const smartApiPlan = buildSmartRagApiPlan({ query: answerFocusQuery, @@ -63,6 +80,14 @@ export async function POST(request: Request) { try { const answerBody = await parseJsonBody(request, answerRequestSchema, "Invalid answer request."); body = answerBody; + if (answerBody.summaryMode) { + return jsonError( + new PublicApiError("Document summaries require the streaming answer endpoint.", 400, { + code: "summary_mode_stream_required", + }), + 400, + ); + } if (isDemoMode()) { return NextResponse.json({ ...buildDemoAnswerPayload(answerBody), interactionId }); } @@ -71,14 +96,29 @@ export async function POST(request: Request) { const access = await publicAccessContext(request, supabase); const accessScope = resolveRetrievalAccessScope(access.ownerId); - const rateLimit = await consumeSubjectApiRateLimit({ - supabase, - subject: access.rateLimitSubject, - bucket: "answer", - allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), - }); - if (rateLimit.limited) { - return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); + if (answerBody.summaryMode) { + const decision = await consumeSummaryRateLimits({ + supabase, + subject: access.rateLimitSubject, + }); + if (decision.rateLimit.limited) { + return rateLimitJsonResponse( + decision.bucket === "document_summarize" + ? "Too many document summary requests. Retry shortly." + : "Too many answer requests. Retry shortly.", + decision.rateLimit, + ); + } + } else { + const rateLimit = await consumeSubjectApiRateLimit({ + supabase, + subject: access.rateLimitSubject, + bucket: "answer", + allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(), + }); + if (rateLimit.limited) { + return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); + } } const scope = await resolveSearchScope({ @@ -104,24 +144,27 @@ export async function POST(request: Request) { const singleDocumentScope = Boolean( answerBody.documentId && !answerBody.documentIds?.length && scope.activeFilterCount === 0, ); - const answer = await answerQuestionWithScope({ - query: answerBody.query, - documentId: singleDocumentScope ? answerBody.documentId : undefined, - documentIds: singleDocumentScope - ? undefined - : (scope.documentIds ?? - answerBody.documentIds ?? - (answerBody.documentId ? [answerBody.documentId] : undefined)), - ownerId: access.ownerId, - accessScope, - allowGlobalSearch: !access.ownerId, - queryMode: answerBody.queryMode, - signal: request.signal, - }); + const answer = + answerBody.summaryMode && answerBody.documentId + ? await summarizeDocument(answerBody.documentId, access.ownerId, { signal: request.signal }) + : await answerQuestionWithScope({ + query: answerBody.query, + documentId: singleDocumentScope ? answerBody.documentId : undefined, + documentIds: singleDocumentScope + ? undefined + : (scope.documentIds ?? + answerBody.documentIds ?? + (answerBody.documentId ? [answerBody.documentId] : undefined)), + ownerId: access.ownerId, + accessScope, + allowGlobalSearch: !access.ownerId, + queryMode: answerBody.queryMode, + signal: request.signal, + }); const governedResponse = buildGovernedAnswerClientResponse(answer); logAnswerDiagnostics({ supabase, - query: answerBody.query, + query: answerBody.summaryMode ? documentSummaryQuestion : answerBody.query, ownerId: access.ownerId, answer: governedResponse.telemetryAnswer, }); diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index 9b7e86db9..e111472a2 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -195,7 +195,10 @@ function streamAnswer( : await resolveSearchScope({ supabase: createAdminClient(), accessScope, - documentIds: body.documentIds ?? (body.documentId ? [body.documentId] : undefined), + documentIds: + body.summaryMode && body.documentId + ? [body.documentId] + : (body.documentIds ?? (body.documentId ? [body.documentId] : undefined)), filters: body.filters, }); sendProgress({ stage: "retrieving" }); diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 6dc499334..782eb1f7a 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -63,6 +63,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: documentIds: [id], action: "Reindex", checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: mode !== "enrichment", staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, }); if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); diff --git a/src/app/api/documents/bulk/reindex/route.ts b/src/app/api/documents/bulk/reindex/route.ts index aa07e4810..6300a2957 100644 --- a/src/app/api/documents/bulk/reindex/route.ts +++ b/src/app/api/documents/bulk/reindex/route.ts @@ -51,6 +51,7 @@ export async function POST(request: Request) { documentIds: documents.map((document) => document.id), action: "Bulk reindex", checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: parsed.mode !== "enrichment", staleAfterMinutes: env.WORKER_STALE_AFTER_MINUTES, }); if (!safety.ok) return NextResponse.json(ingestionMutationSafetyPayload(safety), { status: safety.status }); diff --git a/src/app/api/documents/bulk/route.ts b/src/app/api/documents/bulk/route.ts index 9fe116afb..70b5a7484 100644 --- a/src/app/api/documents/bulk/route.ts +++ b/src/app/api/documents/bulk/route.ts @@ -6,6 +6,7 @@ import { isDemoMode } from "@/lib/env"; import { jsonError, PublicApiError } from "@/lib/http"; import { logger } from "@/lib/logger"; import { safeErrorLogDetails } from "@/lib/privacy"; +import { sourceAuthorityForPublisherCode } from "@/lib/source-authority-registry"; import { invalidateRagCachesForOwner } from "@/lib/rag/rag"; import { createAdminClient } from "@/lib/supabase/admin"; import type { Json, TablesUpdate } from "@/lib/supabase/database.types"; @@ -26,6 +27,7 @@ const bulkMetadataSchema = z.object({ publicationDate: nullableText, jurisdiction: nullableText, publisher: nullableText, + publisherCode: nullableText, sourceType: nullableText, collection: nullableText, category: nullableText, @@ -201,8 +203,17 @@ export async function POST(request: Request) { setMetadataValue(metadata, "extraction_quality", parsed.metadata.extractionQuality); setMetadataValue(metadata, "review_date", parsed.metadata.reviewDate); setMetadataValue(metadata, "publication_date", parsed.metadata.publicationDate); - setMetadataValue(metadata, "jurisdiction", parsed.metadata.jurisdiction); - setMetadataValue(metadata, "publisher", parsed.metadata.publisher); + const publisherCode = parsed.metadata.publisherCode; + if (publisherCode !== undefined && publisherCode !== null && publisherCode.trim()) { + const authority = sourceAuthorityForPublisherCode(publisherCode); + if (!authority) throw new PublicApiError("Unknown publisher code.", 400); + setMetadataValue(metadata, "publisher_code", authority.codes[0]); + setMetadataValue(metadata, "publisher", authority.publisher); + setMetadataValue(metadata, "jurisdiction", authority.jurisdictions[0] ?? null); + } else { + setMetadataValue(metadata, "jurisdiction", parsed.metadata.jurisdiction); + setMetadataValue(metadata, "publisher", parsed.metadata.publisher); + } setMetadataValue(metadata, "source_type", parsed.metadata.sourceType); setMetadataValue(metadata, "collection", parsed.metadata.collection); setMetadataValue(metadata, "category", parsed.metadata.category); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index c3c3515b3..bfc8de55e 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1022,7 +1022,9 @@ export async function POST(request: Request) { const failurePayload = { results: [], telemetry: { - query_class: classifyRagQuery(error.message).queryClass, + query_class: fallbackBody + ? classifyRagQuery(fallbackBody.query).queryClass + : classifyRagQuery(error.message).queryClass, retrieval_strategy: null, failure_code: code, }, @@ -1031,7 +1033,7 @@ export async function POST(request: Request) { logSearchObservation({ supabase, ownerId, - query: "unknown", + query: fallbackBody?.query ?? "unknown", results: [], payload: failurePayload, failure: { diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 8451ae0a6..4d92aae2c 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -8,6 +8,7 @@ import { logger } from "@/lib/logger"; import { writeAuditLog } from "@/lib/audit"; import { consumeSubjectApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { planDocumentName, type DocumentNameSupabase } from "@/lib/document-naming"; +import { inferSourceAuthorityFromIdentity } from "@/lib/source-authority-metadata"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import { probeSupabaseHealth } from "@/lib/supabase/health"; @@ -204,25 +205,54 @@ export async function POST(request: Request) { const title = namePlan.title; const description = uploadMetadata.description; const uploadedAt = new Date().toISOString(); + const identityAuthority = inferSourceAuthorityFromIdentity({ + title, + file_name: file.name, + source_path: storagePath, + }); + const canonicalAuthority = identityAuthority.conflict ? null : identityAuthority.authority; assertUploadNotAborted(request); - const { data: document, error: documentError } = await supabase - .from("documents") - .insert({ - id: documentId, - owner_id: uploadOwnerId, - title, - description, - file_name: file.name, - file_type: file.type, - file_size: file.size, - storage_path: storagePath, + const documentPayload = { + id: documentId, + owner_id: uploadOwnerId, + title, + description, + file_name: file.name, + file_type: file.type, + file_size: file.size, + storage_path: storagePath, + content_hash: contentHash, + metadata: { + source_title: title, + publisher_code: null, + publisher: null, + jurisdiction: "Australia/WA", + version: null, + publication_date: null, + review_date: null, + uploaded_at: uploadedAt, + indexed_at: null, + uploaded_by: uploadOwnerId, + original_file_name: namePlan.originalFileName, + original_title: namePlan.originalTitle, + smart_title_base: namePlan.baseTitle, + smart_title_group_key: namePlan.duplicateGroupKey, + smart_title_duplicate_index: namePlan.duplicateIndex, + smart_title_duplicate_reason: namePlan.duplicateReason, + document_status: "unknown", + clinical_validation_status: "unverified", + extraction_quality: "unknown", + max_upload_mb: env.MAX_UPLOAD_MB, + confidentiality_scope: "guidelines-only", content_hash: contentHash, +<<<<<<< ours status: "queued", metadata: { source_title: title, - publisher: null, - jurisdiction: "Australia/WA", + publisher_code: canonicalAuthority ? (identityAuthority.code ?? canonicalAuthority.codes[0] ?? null) : null, + publisher: canonicalAuthority?.publisher ?? null, + jurisdiction: canonicalAuthority?.jurisdictions[0] ?? "Australia/WA", version: null, publication_date: null, review_date: null, @@ -245,9 +275,22 @@ export async function POST(request: Request) { }) .select() .single(); +======= + }, + }; + + assertUploadNotAborted(request); + const { data: uploadRecord, error: uploadRecordError } = await supabase.rpc( + "create_uploaded_document_with_ingestion_job", + { + p_document: documentPayload, + p_max_attempts: env.WORKER_MAX_ATTEMPTS, + }, + ); +>>>>>>> theirs - if (documentError) { - if (isContentHashDuplicateError(documentError)) { + if (uploadRecordError) { + if (isContentHashDuplicateError(uploadRecordError)) { insertedDocumentId = null; insertedDocumentOwnerId = null; return duplicateUploadResponse({ @@ -257,40 +300,17 @@ export async function POST(request: Request) { storagePath: uploadedPath, }); } - throw new Error(documentError.message); + throw new Error(uploadRecordError.message); + } + + const document = (uploadRecord as { document?: unknown } | null)?.document; + const job = (uploadRecord as { job?: unknown } | null)?.job; + if (!document || !job) { + throw new Error("Upload enqueue RPC returned an invalid response."); } insertedDocumentId = documentId; insertedDocumentOwnerId = uploadOwnerId; - assertUploadNotAborted(request); - const { data: job, error: jobError } = await supabase - .from("ingestion_jobs") - .insert({ - document_id: documentId, - batch_id: null, - status: "pending", - stage: "queued", - progress: 0, - max_attempts: env.WORKER_MAX_ATTEMPTS, - }) - .select() - .single(); - - if (jobError) { - const { error: rollbackDocumentError } = await supabase - .from("documents") - .delete() - .eq("id", documentId) - .eq("owner_id", uploadOwnerId); - if (rollbackDocumentError) { - throw new Error( - `Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`, - ); - } - insertedDocumentId = null; - insertedDocumentOwnerId = null; - throw new Error(jobError.message); - } await writeAuditLog(supabase, { ownerId: uploadOwnerId, diff --git a/src/app/globals.css b/src/app/globals.css index 7e560bc47..1db94159d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -279,7 +279,7 @@ header height and those page-fill floors cannot silently drift apart. */ --shell-header-h: 4rem; /* Keep in sync with mobile-composer-reserve.ts (phone dock clearance). */ - --phone-dock-hidden-pad: 0.75rem; + --phone-dock-hidden-pad: 0rem; --phone-dock-differentials-compare-clearance: 12.5rem; --phone-dock-differentials-compare-compact-clearance: 12.25rem; /* Radius tokens are the single source of truth in @theme above (they also diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 809791d96..cd82c3ad0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata, Viewport } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import localFont from "next/font/local"; import { headers } from "next/headers"; import { AuthProvider } from "@/lib/supabase/client"; import { AccountDataProvider } from "@/components/account-data-provider"; @@ -9,14 +9,16 @@ import { resolveMetadataBase } from "@/lib/metadata-base"; import { APP_THEME_COLORS, THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme"; import "./globals.css"; -const geistSans = Geist({ +const geistSans = localFont({ + src: "../../node_modules/next/dist/next-devtools/server/font/geist-latin.woff2", variable: "--font-geist-sans", - subsets: ["latin"], + display: "swap", }); -const geistMono = Geist_Mono({ +const geistMono = localFont({ + src: "../../node_modules/next/dist/next-devtools/server/font/geist-mono-latin.woff2", variable: "--font-geist-mono", - subsets: ["latin"], + display: "swap", // The mono face is only used deep in the UI (tabular figures, `kbd`, code) and // never in initial/LCP text, so don't preload it on every route — it competes // for the critical-path connection. It still loads on-demand via `swap` when diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx index 0375be696..37b061dbc 100644 --- a/src/app/privacy/page.tsx +++ b/src/app/privacy/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import type { ReactNode } from "react"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; +import { NavigationBackButton } from "@/components/navigation-back-button"; import { cn, eyebrowText, @@ -74,15 +75,18 @@ export default function PrivacyPage() {
-
-

{privacyCopy.pageEyebrow}

-

- {privacyCopy.pageTitle} -

-

- This is draft product information based on the repository's configured behaviour. It is not legal - advice, a final privacy policy, or an assertion of governance approval. -

+
+ +
+

{privacyCopy.pageEyebrow}

+

+ {privacyCopy.pageTitle} +

+

+ This is draft product information based on the repository's configured behaviour. It is not legal + advice, a final privacy policy, or an assertion of governance approval. +

+
diff --git a/src/app/reference/colour-coding/page.tsx b/src/app/reference/colour-coding/page.tsx index d5e0a946e..d5d06bdf1 100644 --- a/src/app/reference/colour-coding/page.tsx +++ b/src/app/reference/colour-coding/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { ClinicalBadge } from "@/components/clinical-dashboard/clinical-badge"; +import { NavigationBackButton } from "@/components/navigation-back-button"; import { cn, eyebrowText, @@ -41,18 +42,21 @@ export default function ColourCodingReferencePage() { >
-
-

Reference

-

- Colour coding reference -

-

- Badges flag important content so clinical screens are faster to scan. The system uses six tones only — - meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay - distinguishable without colour. Governance lives in{" "} - docs/clinical-badge-system-guide.md; this page is generated - from src/lib/semantic-flags.ts. -

+
+ +
+

Reference

+

+ Colour coding reference +

+

+ Badges flag important content so clinical screens are faster to scan. The system uses six tones only — + meaning drives the colour, never the other way round. Danger and warning also carry an icon so they stay + distinguishable without colour. Governance lives in{" "} + docs/clinical-badge-system-guide.md; this page is generated + from src/lib/semantic-flags.ts. +

+
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 51a31a707..0a15b3ec2 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -3073,7 +3073,6 @@ export function ClinicalDashboard({ ); const showAuthPanel = false; const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); - const hasMobileBottomSearch = searchMode !== "answer"; const submittedAnswerSearchActive = activeModeResultKind === "answer" && !answer && canRunSearch && (modeSearchSubmitted || Boolean(submittedUrlQuery)); const showAnswerHome = activeModeResultKind === "answer" && !answer && !loading && !submittedAnswerSearchActive; @@ -3102,6 +3101,9 @@ export function ClinicalDashboard({ (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) || (activeModeResultKind === "differentials" && !modeSearchSubmitted)))); const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined; + const heroComposerBreakpoint = showDesktopHomeComposer || showAnswerHome ? "all" : "sm-up"; + const heroOwnsPhoneComposer = Boolean(desktopHomeComposerSlotId) && heroComposerBreakpoint === "all"; + const hasMobileBottomSearch = searchMode !== "answer" && !heroOwnsPhoneComposer; // Favourites and Tools are content-rich hubs: they share the centred hero but // stay top-aligned so their lists start in a stable position. const centeredModeHome = @@ -3114,7 +3116,7 @@ export function ClinicalDashboard({ ((searchMode === "services" || searchMode === "forms") && !modeSearchSubmitted && !query.trim() && !loading); const differentialsCompareAddonActive = searchMode === "differentials" && modeSearchSubmitted && Boolean(query.trim()); - // Hidden dock pad must stay at 0.75rem — Safari toolbar safe-area recreates a blank band. + // Hidden dock pad must stay at 0rem — Safari toolbar safe-area recreates a blank band. const mobileComposerReserve = resolveMobileComposerReserve( bottomComposerHidden, resolveDashboardVisibleMobileComposerReserve({ @@ -3400,10 +3402,10 @@ export function ClinicalDashboard({ differentialsCompareAddonActive ? differentialsMobileCompareAddonSlotId : undefined } desktopHomeComposerSlotId={desktopHomeComposerSlotId} - // Only the answer home ("How can I help?") keeps the in-flow hero - // pill + privacy notice on phones; every other mode home docks the - // compact pill to the bottom edge below sm. - heroComposerBreakpoint={showAnswerHome ? "all" : "sm-up"} + // Mode homes keep the composer in the centred hero slot at every + // breakpoint so documents, therapy, and the other homes share the + // same phone/tablet structure instead of switching to a bottom dock. + heroComposerBreakpoint={heroComposerBreakpoint} // Answer view: the header overlays the scrolling
at every width // (main reserves matching top padding) so content frosts under the // glass bar, and it slides away/returns with scroll direction. Other diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index a98e0fd06..b16087306 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -1251,9 +1251,9 @@ export function DocumentViewer({ className={cn( "mx-auto grid max-w-[1440px] gap-4 px-3 py-4 sm:gap-5 sm:px-4 sm:py-5 sm:pb-40 lg:grid-cols-[minmax(0,1fr)_480px] lg:items-start lg:px-8", // The visible fixed composer needs endpoint clearance. Once hidden, - // keep only a small content pad so Safari can paint document content + // remove all artificial clearance so Safari can paint document content // beneath its translucent toolbar instead of showing a blank band. - composerScrollHidden ? "max-sm:pb-3" : "max-sm:pb-[calc(9rem+var(--safe-area-bottom))]", + composerScrollHidden ? "max-sm:pb-0" : "max-sm:pb-[calc(9rem+var(--safe-area-bottom))]", )} > {downloadError ? ( diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index d522d0dd7..80a719964 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -94,11 +94,22 @@ const sidebarAccountLibraryItems = [ { id: "favourites" as const, label: "Favourites", icon: Heart, href: "/favourites" }, ] as const; -// Drop any tool whose id is a dev-only app mode from the production nav. Non-mode -// entries (answer, documents, prescribing, tools) are query-param destinations, -// not app modes, so they always stay. NODE_ENV is inlined into the client bundle, -// so this resolves at build time. -const visibleSidebarToolItems = sidebarToolItems.filter((item) => !isAppModeId(item.id) || isAppModeVisible(item.id)); +const primarySidebarToolIds = new Set<(typeof sidebarToolItems)[number]["id"]>([ + "answer", + "documents", + "services", + "forms", + "tools", + "therapy-compass", +]); + +// Keep the persistent sidebar intentionally short. Secondary catalogues and +// specialist workspaces remain reachable through the MODE picker, search, and +// the Tools hub, but they should not all appear in the left rail. Still honour +// app-mode visibility so dev-only modes cannot leak into production nav. +const visibleSidebarToolItems = sidebarToolItems.filter( + (item) => primarySidebarToolIds.has(item.id) && (!isAppModeId(item.id) || isAppModeVisible(item.id)), +); function sidebarItemBadge(item: (typeof sidebarToolItems)[number]): string | undefined { return "badge" in item ? item.badge : undefined; diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 0f2d28401..740357044 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -815,29 +815,29 @@ export function LibraryHealthStrip({ { target: "documents" as const, label: "Documents", - value: loading ? "Loading" : `${indexedDocuments} indexed`, + value: loading ? "" : `${indexedDocuments} indexed`, tone: loading ? toneNeutral : indexedDocuments ? toneSuccess : toneWarning, actionLabel: "Show indexed document files", }, { target: "setup" as const, label: "Setup", - value: `${readyChecks}/${checks.length || fallbackSetupChecks.length} ready`, - tone: readyChecks === (checks.length || fallbackSetupChecks.length) ? toneSuccess : toneWarning, + value: loading ? "" : `${readyChecks}/${checks.length || fallbackSetupChecks.length} ready`, + tone: loading ? toneNeutral : readyChecks === (checks.length || fallbackSetupChecks.length) ? toneSuccess : toneWarning, actionLabel: "Show setup checks", }, { target: "indexing" as const, label: "Indexing", - value: activeJobs + activeBatches ? `${activeJobs + activeBatches} active` : "Idle", - tone: activeJobs + activeBatches ? toneInfo : toneNeutral, + value: loading ? "" : (activeJobs + activeBatches ? `${activeJobs + activeBatches} active` : "Idle"), + tone: loading ? toneNeutral : (activeJobs + activeBatches ? toneInfo : toneNeutral), actionLabel: "Show indexing progress", }, { target: "failures" as const, label: "Failures", - value: failedWork ? `${failedWork} needs review` : "None", - tone: failedWork ? toneDanger : toneNeutral, + value: loading ? "" : (failedWork ? `${failedWork} needs review` : "None"), + tone: loading ? toneNeutral : (failedWork ? toneDanger : toneNeutral), actionLabel: "Show failed indexing work", }, ]; @@ -847,6 +847,7 @@ export function LibraryHealthStrip({ data-testid="library-health-strip" className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 shadow-[var(--shadow-inset)]" aria-label="Library health" + style={{ containIntrinsicSize: "auto 76px", contentVisibility: "auto" }} >

Library health

@@ -863,9 +864,15 @@ export function LibraryHealthStrip({ item.tone, )} aria-label={item.actionLabel} + aria-busy={loading} + style={{ containIntrinsicSize: "auto 48px", contentVisibility: "auto" }} >

{item.label}

-

{item.value}

+ {loading ? ( +
+ ) : ( +

{item.value}

+ )} ))}
diff --git a/src/components/clinical-dashboard/answer-status.tsx b/src/components/clinical-dashboard/answer-status.tsx index 679a67fa1..559d3791e 100644 --- a/src/components/clinical-dashboard/answer-status.tsx +++ b/src/components/clinical-dashboard/answer-status.tsx @@ -162,9 +162,24 @@ export function AnswerProgressStepper({ active: boolean; onStop: () => void; }) { +<<<<<<< ours +<<<<<<< ours const [now, setNow] = useState(() => Date.now()); const latest = events.at(-1) ?? null; const finished = latest?.stage === "complete"; +======= +======= +>>>>>>> theirs + const latest = events.at(-1) ?? null; + const finished = latest?.stage === "complete"; + const now = useClientTime({ + fallback: startedAt ?? 0, + updateInterval: active && !finished && startedAt ? 1_000 : undefined, + }); +<<<<<<< ours +>>>>>>> theirs +======= +>>>>>>> theirs const currentStep = latest ? answerProgressStepIndex(latest.stage) : 0; useEffect(() => { diff --git a/src/components/clinical-dashboard/document-admin.tsx b/src/components/clinical-dashboard/document-admin.tsx index e5d8b8c4f..94771047a 100644 --- a/src/components/clinical-dashboard/document-admin.tsx +++ b/src/components/clinical-dashboard/document-admin.tsx @@ -43,6 +43,7 @@ import { panelSubtle, primaryControl, sourceCard, + SourceDesignationBadge, SourceProvenance, SourceStatusBadge, textMuted, @@ -63,6 +64,7 @@ import { type SmartDocumentTagTier, tagSearchText, } from "@/lib/document-tags"; +import { classifySourceAuthority, type SourceDesignation } from "@/lib/source-authority-registry"; import type { ClinicalDocument, DocumentLabel, DocumentLabelType } from "@/lib/types"; import { emptyStates } from "@/lib/ui-copy"; @@ -572,6 +574,7 @@ export function DocumentDrawer({ const [selectedSite, setSelectedSite] = useState("all"); const [selectedTopic, setSelectedTopic] = useState("all"); const [selectedPopulation, setSelectedPopulation] = useState("all"); + const [selectedDesignation, setSelectedDesignation] = useState("all"); const [showNeedsReviewOnly, setShowNeedsReviewOnly] = useState(false); const [collectionDraft, setCollectionDraft] = useState(""); @@ -703,6 +706,13 @@ export function DocumentDrawer({ if (!hasPopMatch) return false; } + if ( + selectedDesignation !== "all" && + classifySourceAuthority(document.metadata).designation !== selectedDesignation + ) { + return false; + } + // Filter by Needs Review if (showNeedsReviewOnly) { const profile = documentOrganizationProfile(document); @@ -711,7 +721,9 @@ export function DocumentDrawer({ const labelText = tagSearchText(document); const summaryText = document.summary?.summary ?? ""; - const haystack = `${document.title} ${document.file_name} ${labelText} ${summaryText}`.toLowerCase(); + const sourceDesignation = classifySourceAuthority(document.metadata).designation; + const haystack = + `${document.title} ${document.file_name} ${labelText} ${summaryText} ${sourceDesignation}`.toLowerCase(); return haystack.includes(filterValue); }) .sort((left, right) => { @@ -741,7 +753,7 @@ export function DocumentDrawer({ {/* Dynamic Browse Library Filters */} -
+
+
+ + +
{/* Admin Queue Toggle */} @@ -1064,6 +1096,7 @@ export function DocumentDrawer({
+ {isAdminMode ? ( >>>>>> theirs * Builds the non-empty clinical detail sections used by the clinical notes view. * * @param answer - The answer from which to derive clinical detail sections. diff --git a/src/components/clinical-dashboard/favourites-hub.tsx b/src/components/clinical-dashboard/favourites-hub.tsx index a736312bb..f27e5e890 100644 --- a/src/components/clinical-dashboard/favourites-hub.tsx +++ b/src/components/clinical-dashboard/favourites-hub.tsx @@ -317,7 +317,7 @@ export function FavouritesHub({
- + +
+ - Detailed - + Density controls coming soon +
diff --git a/src/components/document-viewer/source-panels.tsx b/src/components/document-viewer/source-panels.tsx index 3d2804146..9ef71ec53 100644 --- a/src/components/document-viewer/source-panels.tsx +++ b/src/components/document-viewer/source-panels.tsx @@ -194,6 +194,39 @@ function looksLikeTableText(value?: string | null) { return Boolean(value?.includes("|") && value.split("|").filter((cell) => cell.trim()).length >= 3); } +function formatPercent(value: number) { + return `${Math.round(value * 100)}%`; +} + +function imageAspectRatio(image: ImageRow) { + if (!image.width || !image.height || image.width <= 0 || image.height <= 0) return null; + return image.width / image.height; +} + +function tableQualityWarnings(image: ImageRow, hasStructuredTable: boolean) { + const warnings: string[] = []; + if (image.rowsTruncated) { + warnings.push( + image.rowCount + ? `Extracted table is partial (${image.rowCount} source rows; preview is capped).` + : "Extracted table is partial.", + ); + } + if (typeof image.cropCompleteness === "number" && image.cropCompleteness < 0.82) { + warnings.push(`Source crop may be incomplete (${formatPercent(image.cropCompleteness)} completeness).`); + } + if (typeof image.structuredExtractionConfidence === "number" && image.structuredExtractionConfidence < 0.58) { + warnings.push(`Structured extraction confidence is low (${formatPercent(image.structuredExtractionConfidence)}).`); + } + if (typeof image.ocrTextDensity === "number" && image.ocrTextDensity < 0.18) { + warnings.push("OCR/text density is low; verify wording against the source image."); + } + if (!hasStructuredTable && image.source_kind === "table_crop") { + warnings.push("No reliable generated table was available; use the source image."); + } + return warnings; +} + export function DocumentImage({ image }: { image: ImageRow }) { const endpoint = `/api/images/${image.id}/signed-url`; @@ -214,6 +247,12 @@ export function DocumentImage({ image }: { image: ImageRow }) { columns: image.tableColumns, }); const tableCaption = tableHeading || cleanCaption || "Document table"; + const warnings = tableQualityWarnings(image, hasStructuredTable); + const sourceImageFirst = + !hasStructuredTable || + image.rowsTruncated === true || + (typeof image.cropCompleteness === "number" && image.cropCompleteness < 0.82) || + (typeof image.structuredExtractionConfidence === "number" && image.structuredExtractionConfidence < 0.58); const showImageCaptionLine = cleanCaption && cleanCaption !== tableCaption; const displayLabels = smartEvidenceTags( image.labels, @@ -234,7 +273,8 @@ export function DocumentImage({ image }: { image: ImageRow }) { caption={tableHeading || cleanCaption || undefined} failureLabel="Image preview failed." retryLabel="Retry" - className="w-full" + className="max-h-[70dvh] min-h-40 w-full" + aspectRatio={imageAspectRatio(image)} zoomable />
@@ -252,6 +292,7 @@ export function DocumentImage({ image }: { image: ImageRow }) { compact={false} expandOnMobile dialogTitle={tableCaption} + lowConfidenceFallback={imageBlock} /> {!hasStructuredTable && image.tableTextSnippet ? (

{image.tableTextSnippet}

@@ -271,7 +312,17 @@ export function DocumentImage({ image }: { image: ImageRow }) { ? ` · ${image.clinicalUseClass.replaceAll("_", " ")}` : ""}

- {hasStructuredTable ? ( + {warnings.length ? ( +
+

Verify table formatting against the source.

+
    + {warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null} + {hasStructuredTable && !sourceImageFirst ? ( <> {figcaptionBlock}
diff --git a/src/components/document-viewer/types.ts b/src/components/document-viewer/types.ts index 386e3f7a1..9df0af103 100644 --- a/src/components/document-viewer/types.ts +++ b/src/components/document-viewer/types.ts @@ -27,6 +27,16 @@ export type ImageRow = { accessibleTableMarkdown?: string | null; tableRows?: string[][] | null; tableColumns?: string[] | null; + rowCount?: number | null; + rowsTruncated?: boolean | null; + columnCount?: number | null; + width?: number | null; + height?: number | null; + cropCompleteness?: number | null; + imageQualityScore?: number | null; + ocrTextDensity?: number | null; + structuredExtractionConfidence?: number | null; + retainedForDocumentView?: boolean | null; }; export type TableFactRow = { diff --git a/src/components/forms/form-detail-page.tsx b/src/components/forms/form-detail-page.tsx index 578ea0abf..3580bb084 100644 --- a/src/components/forms/form-detail-page.tsx +++ b/src/components/forms/form-detail-page.tsx @@ -1,8 +1,6 @@ "use client"; -import { useRouter } from "next/navigation"; import { - ArrowLeft, Bookmark, BookmarkCheck, CalendarDays, @@ -44,8 +42,7 @@ import { toneWarning, } from "@/components/ui-primitives"; import { FormCodeBadge, splitFormCode } from "@/components/forms/form-code-badge"; -import { appModeHomeHref } from "@/lib/app-modes"; -import { formCatalogDetails, type FormRecord } from "@/lib/form-ranker"; +import { formCatalogDetails, formTitleForCode, type FormRecord } from "@/lib/form-catalog"; import type { ServiceChipTone, ServiceContact, ServiceCriterion, ServiceSummaryCard } from "@/lib/service-ranker"; import { useAccountData } from "@/components/account-data-provider"; @@ -264,13 +261,31 @@ function PathwayContextCard({ testId?: string; }) { const details = formCatalogDetails(form); - const pathwayItems = (items: string[] | undefined, fallbackCode: string, fallbackTitle: string) => + const [activeTab, setActiveTab] = useState<"pathway" | "source">("pathway"); + const pathwayItems = (items: string[] | undefined, emptyTitle: string, emptyMeta: string) => items?.length - ? items.map((item) => ({ code: /^\d{1,2}[a-z]?(?:\s+attachment)?$/i.test(item) ? item : "Step", title: item })) - : [{ code: fallbackCode, title: fallbackTitle }]; - const beforeForms = pathwayItems(details?.before, "Check", "Confirm prerequisites on the current form"); - const parallelForms = pathwayItems(details?.parallel, "None", "No parallel form listed in the imported pathway"); - const afterForms = pathwayItems(details?.after, "Next", "Confirm the next lawful step and owner"); + ? items.map((item) => { + const knownTitle = formTitleForCode(item); + return { + code: knownTitle ? item : "Context", + title: knownTitle ?? item, + meta: knownTitle ? `Form ${item}` : "Pathway step", + isEmpty: false, + }; + }) + : [{ code: "None", title: emptyTitle, meta: emptyMeta, isEmpty: true }]; + const beforeForms = pathwayItems(details?.before, "No form is listed before this step", "No prior form"); + const parallelForms = pathwayItems(details?.parallel, "No parallel form is listed for this step", "No parallel form"); + const afterForms = pathwayItems( + details?.after, + "No next form is listed; confirm the lawful off-ramp or local workflow", + "No next form", + ); + const confirmChecks = [ + ...(details?.preUseChecks ?? []), + ...(details?.copies ? [details.copies] : []), + ...(details?.safetyPearl ? [details.safetyPearl] : []), + ].filter((value, index, values) => value.trim().length > 0 && values.indexOf(value) === index); return (
-
- +
+ +
-
-
- -

Before

-
- {beforeForms.map((item) => ( -
- -

{item.title}

-
- ))} + {activeTab === "pathway" ? ( +
+
+ +

Before

+
+ {beforeForms.map((item) => ( +
+ +

+ {item.meta} + {item.isEmpty ? "" : " — "} + {item.isEmpty ? item.title : {item.title}} +

+
+ ))} +
-
-
- -

Current

-
- -

{form.title}

+
+ +

Current

+
+ +

{form.title}

+
+ + You are here + +

{displayText(form.subtitle)}

- - You are here - -

{displayText(form.subtitle)}

-
-
- -

Parallel

-
- {parallelForms.map((item) => ( -
- -

{item.title}

-
- ))} +
+ +

Parallel

+
+ {parallelForms.map((item) => ( +
+ +

+ {item.meta} + {item.isEmpty ? "" : " — "} + {item.isEmpty ? item.title : {item.title}} +

+
+ ))} +
-
-
- -

After

-
- {afterForms.map((item) => ( -
- -

{item.title}

-
- ))} +
+ +

After

+
+ {afterForms.map((item) => ( +
+ +

+ {item.meta} + {item.isEmpty ? "" : " — "} + {item.isEmpty ? item.title : {item.title}} +

+
+ ))} +
-
- {criteria.length ? (

Confirm

- {criteria.slice(0, 3).map((criterion) => ( + {confirmChecks.slice(0, 4).map((check) => ( - {criterion.tone === "reject" ? ( - - ) : ( - - )} - {criterion.label} + + {check.replace(/^Before use:\s*/i, "Before use: ")} ))} + {criteria + .filter((criterion) => criterion.tone === "reject") + .slice(0, 1) + .map((criterion) => ( + + + Avoid: {criterion.label} + + ))}
- ) : null} -
- +
); } @@ -507,7 +618,6 @@ function InfoRow({ label, value, icon: Icon }: { label: string; value: string | } export function FormDetailPage({ form }: { form: FormRecord }) { - const router = useRouter(); const accountData = useAccountData(); const saved = accountData.isSaved("form", form.slug); const [notice, setNotice] = useState(null); @@ -523,10 +633,6 @@ export function FormDetailPage({ form }: { form: FormRecord }) { const criteria = form.criteria ?? []; const relatedTags = useMemo(() => [...(form.tags ?? []), ...(form.catchments ?? [])].slice(0, 8), [form]); - function goBack() { - router.push(appModeHomeHref("forms", { focus: true })); - } - async function copyValue(value: string | null | undefined, label: string) { if (!hasText(value)) { setNotice("Nothing available to copy"); @@ -584,10 +690,6 @@ export function FormDetailPage({ form }: { form: FormRecord }) { ) : null}
-
diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index b0e77b08d..7b198cb2f 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -261,7 +261,7 @@ export function ModeHomeTemplate({ {actions?.length ? (
{actions.map((action, index) => { const ActionIcon = action.icon; @@ -285,8 +285,8 @@ export function ModeHomeTemplate({ ); const actionClassName = cn( - "mode-home-action group grid min-h-[4.4rem] w-full grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] items-center gap-3 bg-[color:var(--surface)] px-4 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 sm:min-h-[4.75rem] sm:grid-cols-[2.75rem_minmax(0,1fr)_1rem] sm:gap-3 sm:rounded-lg sm:border sm:border-[color:var(--border)] sm:px-4 sm:py-3.5 sm:shadow-[var(--shadow-card)] lg:min-h-[4.75rem] lg:px-5", - index > 0 && "border-t border-[color:var(--border)] sm:border-t-[color:var(--border)]", + "mode-home-action group grid min-h-[4.4rem] w-full grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] items-center gap-3 bg-[color:var(--surface)] px-4 py-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:relative focus-visible:z-10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[color:var(--focus)] disabled:cursor-wait disabled:opacity-60 sm:min-h-[4.4rem] sm:grid-cols-[2.5rem_minmax(0,1fr)_1.25rem] sm:px-4 sm:py-3 lg:min-h-[4.75rem] lg:grid-cols-[2.75rem_minmax(0,1fr)_1rem] lg:gap-3 lg:rounded-lg lg:border lg:border-[color:var(--border)] lg:px-5 lg:py-3.5 lg:shadow-[var(--shadow-card)]", + index > 0 && "border-t border-[color:var(--border)]", ); if (action.href) { diff --git a/src/components/navigation-back-button.tsx b/src/components/navigation-back-button.tsx new file mode 100644 index 000000000..424e50941 --- /dev/null +++ b/src/components/navigation-back-button.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { cn } from "@/components/ui-primitives"; + +type NavigationBackButtonProps = { + label?: string; + fallbackHref?: string; + className?: string; + onClick?: () => void; +}; + +type NavigationBackButtonControlProps = Pick & { + onClick: () => void; +}; + +function NavigationBackButtonControl({ label = "Go back", className, onClick }: NavigationBackButtonControlProps) { + return ( + + ); +} + +function RoutedNavigationBackButton({ + label, + fallbackHref = "/", + className, +}: Omit) { + const router = useRouter(); + + return ( + { + router.push(fallbackHref); + }} + /> + ); +} + +export function NavigationBackButton({ onClick, ...props }: NavigationBackButtonProps) { + return onClick ? ( + + ) : ( + + ); +} diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index 5fbd9b601..226a31af2 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -23,6 +23,7 @@ import { } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; +import { NavigationBackButton } from "@/components/navigation-back-button"; import { cn, clinicalDivider, @@ -526,7 +527,8 @@ export function PatientSafetyPlan() { {/* Tool header */}
-
+
+ diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index 17dfff308..763616455 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -41,6 +41,7 @@ import { toneWarning, } from "@/components/ui-primitives"; import { appModeHomeHref } from "@/lib/app-modes"; +import { compactBestUseTitle } from "@/lib/compact-best-use-title"; import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; import { serviceNavigatorQuery, @@ -64,6 +65,10 @@ function displayText(value: string | null | undefined, fallback = missingText) { return hasText(value) ? value.trim() : fallback; } +function bestUseCardTitle(bestUse: string | null | undefined) { + return hasText(bestUse) ? compactBestUseTitle(bestUse) : "Assess service fit"; +} + function chipToneClass(tone: ServiceStatusChip["tone"] | undefined | null) { if (tone === "danger") return toneDanger; if (tone === "info") return toneInfo; @@ -77,7 +82,7 @@ function renderSummaryIcon(card: ServiceSummaryCard) { if (card.id === "route") return ; if (card.id === "eligibility") return ; if (card.id === "cost") return ; - if (card.id === "confidence" || card.id === "confirm") return ; + if (card.id === "confidence" || card.id === "best-use") return ; return ; } @@ -149,10 +154,18 @@ function summaryCardsFor(service: ServiceRecord): ServiceSummaryCard[] { { id: "eligibility", label: "Eligibility", title: displayText(service.eligibility), detail: "Referral fit" }, { id: "cost", label: "Cost", title: displayText(service.cost), detail: "Funding detail" }, { - id: "confirm", - label: "Confirm", - title: service.verification?.confidence ?? "Unknown", - detail: service.source?.status ?? "Source status unknown", + id: "best-use", + label: "Best use", +<<<<<<< ours +<<<<<<< ours + title: displayText(service.bestUse, "Assess service fit"), +======= + title: bestUseCardTitle(service.bestUse), +>>>>>>> theirs +======= + title: bestUseCardTitle(service.bestUse), +>>>>>>> theirs + detail: "Clinical fit and referral priority", }, ]; } @@ -439,11 +452,19 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { title: displayText(service.route), detail: displayText(primaryContact?.detail, "Primary route"), }, - { - id: "confirm", - label: "Confirm", - title: verified ? "Verified source" : "Confirm before use", - detail: verified ? "Local details checked" : "Confirm locally before use", + summaryCardById.get("best-use") ?? { + id: "best-use", + label: "Best use", +<<<<<<< ours +<<<<<<< ours + title: displayText(service.bestUse, "Assess service fit"), +======= + title: bestUseCardTitle(service.bestUse), +>>>>>>> theirs +======= + title: bestUseCardTitle(service.bestUse), +>>>>>>> theirs + detail: "Clinical fit and referral priority", }, summaryCardById.get("eligibility") ?? { id: "eligibility", diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index 2b8feb1d2..d5d412c08 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { FileQuestion, FileSearch, Loader2, MapPinned, Route, ShieldAlert, ShieldCheck, Users } from "lucide-react"; +import { FileQuestion, FileSearch, Loader2, MapPinned, Route, ShieldAlert, Users } from "lucide-react"; import { ModeHomeMain, @@ -139,9 +139,9 @@ export function ServicesHomePage({ defaultServiceSlug = null }: { defaultService footer={ hasRegistryRecords ? ( diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index a9b0f5a71..eb6a45905 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -15,6 +15,7 @@ import { ShieldAlert, ShieldCheck, SlidersHorizontal, + Sparkles, Users, X, type LucideIcon, @@ -40,7 +41,15 @@ import { sortResultItems } from "@/lib/result-sort"; import { UniversalSearchAlsoMatches } from "@/components/clinical-dashboard/universal-search-also-matches"; import { useResultSort } from "@/components/use-result-sort"; -const defaultQuery = "13YARN crisis support aboriginal phone"; +<<<<<<< ours +<<<<<<< ours +const defaultQuery = "13YARN crisis Aboriginal and Torres Strait Islander phone"; +======= +const defaultQuery = "13YARN crisis Aboriginal Torres Strait Islander phone"; +>>>>>>> theirs +======= +const defaultQuery = "13YARN crisis Aboriginal Torres Strait Islander phone"; +>>>>>>> theirs function text(value: string | null | undefined, fallback = "Confirm locally") { return value?.trim() ? value.trim() : fallback; @@ -59,7 +68,9 @@ function chipTone(tone: ServiceStatusChip["tone"] | undefined | null) { function serviceChipLabel(chip: ServiceStatusChip) { const label = text(chip.label, "Status"); - if (label.toLowerCase().includes("aboriginal and torres strait islander")) return "ATSI-specific"; + if (label.toLowerCase().includes("aboriginal and torres strait islander")) { + return "Aboriginal and Torres Strait Islander-specific"; + } return label; } @@ -641,70 +652,83 @@ export function ServicesNavigatorPage() { /> ) : ( <> -
-
-
- - {displayedMatches.length} +
+
+
+
+<<<<<<< ours +<<<<<<< ours + +======= + +>>>>>>> theirs +======= + +>>>>>>> theirs +
-

+

Referral matches

-

+

{displayedMatches.length} referral {displayedMatches.length === 1 ? "match" : "matches"}

-

- - {sortValue === "alpha" - ? "Sorted A–Z for quick known-service lookup." - : "Best fit for crisis, ATSI-specific phone referral."} - - - {sortValue === "alpha" - ? "Sorted A–Z for quick known-service lookup." - : "Ranked for crisis support, ATSI-specific access, and phone referral."} - +

+ {sortValue === "alpha" + ? "Sorted A–Z for quick known-service lookup." + : "Prioritised for crisis support, culturally safe access, and phone referral."}

+ + Advanced service filters are coming soon. Use the quick filters below for now. +
-
+
- {["Best fit", "Crisis", "ATSI-specific", "Phone referral", "Free", "WA"].map((chip, index) => ( + {[ + { label: "Best fit", query: defaultQuery }, + { label: "Crisis", query: "crisis" }, +<<<<<<< ours +<<<<<<< ours + { label: "Culturally safe", query: "Aboriginal and Torres Strait Islander" }, +======= + { label: "Culturally safe", query: "Aboriginal Torres Strait Islander" }, +>>>>>>> theirs +======= + { label: "Culturally safe", query: "Aboriginal Torres Strait Islander" }, +>>>>>>> theirs + { label: "Phone referral", query: "phone referral" }, + { label: "Free", query: "free" }, + { label: "WA", query: "WA" }, + ].map((chip, index) => ( ))}
diff --git a/src/components/ui-primitives.tsx b/src/components/ui-primitives.tsx index 5b942f031..c546c5ec8 100644 --- a/src/components/ui-primitives.tsx +++ b/src/components/ui-primitives.tsx @@ -1,12 +1,15 @@ -import { Ban, Loader2, TriangleAlert, X, type LucideIcon } from "lucide-react"; +import { Ban, Landmark, Loader2, ShieldCheck, TriangleAlert, X, type LucideIcon } from "lucide-react"; import type { ButtonHTMLAttributes, ReactNode } from "react"; import { extractionQualityLabel, formatClinicalDate, normalizeSourceMetadata, + sourceDesignationDescription, + sourceDesignationLabel, sourceStatusLabel, validationStatusLabel, } from "@/lib/source-metadata"; +import { classifySourceAuthority } from "@/lib/source-authority-registry"; export function cn(...classes: Array) { return classes.filter(Boolean).join(" "); @@ -322,6 +325,38 @@ export function ToggleSwitch({ type IconComponent = LucideIcon; +export function SourceDesignationBadge({ metadata, className }: { metadata?: unknown; className?: string }) { + const source = normalizeSourceMetadata(metadata); + const classification = classifySourceAuthority(source); + const toneClassName = + classification.designation === "official" + ? toneSuccess + : classification.designation === "trusted" + ? toneInfo + : toneWarningQuiet; + const Icon = + classification.designation === "official" + ? Landmark + : classification.designation === "trusted" + ? ShieldCheck + : TriangleAlert; + + return ( + + + ); +} + export function SourceStatusBadge({ metadata, className, @@ -367,6 +402,7 @@ export function SourceProvenance({ metadata }: { metadata?: unknown }) { // validation and extraction-quality labels always stay — they are clinical // governance signals, not noise. const items = [ + sourceDesignationLabel(classifySourceAuthority(source).designation), validationStatusLabel(source), reviewDate === "Unknown" ? null : `Review ${reviewDate}`, source.jurisdiction, diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index f0b9eba2b..685ae1310 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -222,6 +222,7 @@ export function Sheet({ window.setTimeout(() => { if ( restoreTarget.isConnected && + typeof document !== "undefined" && document.activeElement !== restoreTarget && document.activeElement === document.body ) { diff --git a/src/lib/answer-render-policy.ts b/src/lib/answer-render-policy.ts index 642144598..7051ac415 100644 --- a/src/lib/answer-render-policy.ts +++ b/src/lib/answer-render-policy.ts @@ -142,7 +142,7 @@ function answerRelevance(answer: RagAnswer): EvidenceRelevance | undefined { function deriveTrust(answer: RagAnswer): AnswerRenderTrust { const relevance = answerRelevance(answer); const retrievalBlocked = answer.retrievalDiagnostics?.gateStatus === "blocked"; - const sourceBacked = relevance?.isSourceBacked !== false; + const sourceBacked = relevance?.isSourceBacked === true; const hasFaithfulnessWarning = Boolean(answer.faithfulnessWarning || answer.unverifiedNumericTokens?.length); const evidenceGap = answer.responseMode === "evidence_gap"; diff --git a/src/lib/compact-best-use-title.ts b/src/lib/compact-best-use-title.ts new file mode 100644 index 000000000..a6df78c0a --- /dev/null +++ b/src/lib/compact-best-use-title.ts @@ -0,0 +1,19 @@ +/** Compact pipe/newline-joined catalog blobs for quick-fact card titles. */ +export function compactBestUseTitle(text: string, maxLength = 140): string { + const parts = text + .split(/[|\n\r]+/) + .map((line) => line.trim()) + .filter(Boolean); + const unique: string[] = []; + for (const part of parts) { + const key = part.toLowerCase().replace(/\s+/g, " "); + if (!unique.some((entry) => entry.toLowerCase().replace(/\s+/g, " ") === key)) { + unique.push(part); + } + } + + const primary = unique[0] ?? text.trim(); + if (primary.length <= maxLength) return primary; + const truncated = primary.slice(0, Math.max(0, maxLength - 1)).trimEnd(); + return truncated ? `${truncated}…` : primary.slice(0, maxLength); +} diff --git a/src/lib/document-detail-contract.ts b/src/lib/document-detail-contract.ts index 7d90e136f..acf75bf43 100644 --- a/src/lib/document-detail-contract.ts +++ b/src/lib/document-detail-contract.ts @@ -28,6 +28,16 @@ export type DocumentDetailImage = { accessibleTableMarkdown?: string | null; tableRows?: string[][] | null; tableColumns?: string[] | null; + rowCount?: number | null; + rowsTruncated?: boolean | null; + columnCount?: number | null; + width?: number | null; + height?: number | null; + cropCompleteness?: number | null; + imageQualityScore?: number | null; + ocrTextDensity?: number | null; + structuredExtractionConfidence?: number | null; + retainedForDocumentView?: boolean | null; }; export type DocumentDetailTableFact = { diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts index 03bbf24e3..b72bfd1ca 100644 --- a/src/lib/document-detail.ts +++ b/src/lib/document-detail.ts @@ -112,6 +112,17 @@ function metadataStringArray(metadata: Record, key: string) { return items.length ? items : null; } +function metadataNumber(metadata: Record, key: string) { + const value = metadata[key]; + const number = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; + return Number.isFinite(number) ? number : null; +} + +function metadataBoolean(metadata: Record, key: string) { + const value = metadata[key]; + return typeof value === "boolean" ? value : null; +} + function withImageTableMetadata(image: T) { const metadata = safeMetadata(image.metadata); const rawTableText = metadataText(metadata, "table_text"); @@ -129,6 +140,14 @@ function withImageTableMetadata(image: T) { accessibleTableMarkdown: metadataText(metadata, "accessible_table_markdown") ?? rawTableText, tableRows: metadataStringArrayRows(metadata, "table_rows"), tableColumns: metadataStringArray(metadata, "table_columns"), + rowCount: metadataNumber(metadata, "row_count"), + rowsTruncated: metadataBoolean(metadata, "rows_truncated"), + columnCount: metadataNumber(metadata, "column_count"), + cropCompleteness: metadataNumber(metadata, "crop_completeness"), + imageQualityScore: metadataNumber(metadata, "image_quality_score"), + ocrTextDensity: metadataNumber(metadata, "ocr_text_density"), + structuredExtractionConfidence: metadataNumber(metadata, "structured_extraction_confidence"), + retainedForDocumentView: metadataBoolean(metadata, "retained_for_document_view"), }; } diff --git a/src/lib/form-catalog.ts b/src/lib/form-catalog.ts index 852e4127f..210deb90f 100644 --- a/src/lib/form-catalog.ts +++ b/src/lib/form-catalog.ts @@ -6,6 +6,7 @@ import type { ServiceChipTone, ServiceRecord } from "@/lib/services"; export { formCatalogDetails } from "@/lib/form-ranker"; export type { FormAvailability, FormCatalogDetails } from "@/lib/form-ranker"; +export type FormRecord = ServiceRecord; export const officialFormsRegisterUrl = "https://www.chiefpsychiatrist.wa.gov.au/laws-and-rights/legislation/mental-health-act-2014-forms/"; @@ -420,6 +421,11 @@ function toFormRecord(details: FormCatalogDetails): ServiceRecord { }; } +export function formTitleForCode(code: string) { + const normalized = normalizeCode(code); + return officialForms.find((form) => normalizeCode(form.code) === normalized)?.title ?? null; +} + export function loadFormCatalogDetails(): FormCatalogDetails[] { return officialForms.map(detailsFor); } diff --git a/src/lib/ingestion-mutation-safety.ts b/src/lib/ingestion-mutation-safety.ts index 365ac6c06..e39741b62 100644 --- a/src/lib/ingestion-mutation-safety.ts +++ b/src/lib/ingestion-mutation-safety.ts @@ -25,7 +25,8 @@ export type IngestionJobRow = { max_attempts: number | null; }; -export type IngestionMutationSafetyReason = "ready" | "supabase_unavailable" | "active_jobs" | "stale_processing_jobs"; +export type IngestionMutationSafetyReason = + "ready" | "supabase_unavailable" | "active_jobs" | "active_agent_enrichment" | "stale_processing_jobs"; export type IngestionMutationSafetyResult = | { @@ -64,7 +65,12 @@ function isStaleProcessingJob(job: IngestionJobRow, staleAfterMinutes: number, n export const activeIngestionJobColumns = "id,document_id,status,stage,locked_at,updated_at,error_message,attempt_count,max_attempts"; -function activeJobMessage(documentCount: number, staleCount: number) { +function activeJobMessage(documentCount: number, staleCount: number, activeAgentEnrichmentCount = 0) { + if (activeAgentEnrichmentCount > 0) { + return documentCount === 1 + ? "Document has an active agent-enrichment pass. Wait for it to finish before reindexing." + : "One or more selected documents have an active agent-enrichment pass. Wait for it to finish before reindexing."; + } if (staleCount > 0) { return staleCount === 1 ? "A selected document has a stale processing ingestion job. Run queue recovery before reindexing." @@ -85,14 +91,25 @@ export function buildActiveJobsSafetyResult( staleAfterMinutes: number, checkedAt: string, now: Date = new Date(), + reason: "active_jobs" | "active_agent_enrichment" = "active_jobs", ): Extract { const staleProcessingJobs = activeJobs.filter((job) => isStaleProcessingJob(job, staleAfterMinutes, now.getTime())); + const resolvedReason = + reason === "active_agent_enrichment" + ? "active_agent_enrichment" + : staleProcessingJobs.length > 0 + ? "stale_processing_jobs" + : "active_jobs"; return { ok: false, status: 409, checkedAt, - reason: staleProcessingJobs.length > 0 ? "stale_processing_jobs" : "active_jobs", - message: activeJobMessage(activeJobs.length, staleProcessingJobs.length), + reason: resolvedReason, + message: activeJobMessage( + activeJobs.length, + staleProcessingJobs.length, + resolvedReason === "active_agent_enrichment" ? activeJobs.length : 0, + ), activeJobs, staleProcessingJobs, }; @@ -163,6 +180,7 @@ export async function checkIngestionMutationSafety(args: { documentIds: string[]; action: string; checkActiveJobs?: boolean; + checkActiveAgentEnrichmentJobs?: boolean; staleAfterMinutes: number; now?: Date; }): Promise { @@ -207,6 +225,41 @@ export async function checkIngestionMutationSafety(args: { return buildActiveJobsSafetyResult(activeJobs, args.staleAfterMinutes, health.checkedAt, args.now); } + if (args.checkActiveAgentEnrichmentJobs) { + const nowMs = (args.now ?? new Date()).getTime(); + const client = args.supabase as unknown as SupabaseClient; + const { data: agentJobs, error: agentError } = await client + .from("indexing_v3_agent_jobs") + .select("document_id,status,locked_at,updated_at") + .in("document_id", uniqueDocumentIds) + .eq("status", "processing"); + if (agentError) throw new Error(agentError.message); + + const activeAgentJobs = ((agentJobs ?? []) as AgentEnrichmentJobRow[]) + .filter((job) => Boolean(job.document_id) && isActiveAgentEnrichmentJob(job, args.staleAfterMinutes, nowMs)) + .map((job, index): IngestionJobRow => ({ + id: `agent-enrichment:${job.document_id ?? index}`, + document_id: job.document_id, + status: job.status, + stage: "agent_enrichment", + locked_at: job.locked_at, + updated_at: job.updated_at, + error_message: null, + attempt_count: null, + max_attempts: null, + })); + + if (activeAgentJobs.length > 0) { + return buildActiveJobsSafetyResult( + activeAgentJobs, + args.staleAfterMinutes, + health.checkedAt, + args.now, + "active_agent_enrichment", + ); + } + } + return { ok: true, checkedAt: health.checkedAt, diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts index 9e468bd34..fdb7330fb 100644 --- a/src/lib/search-scope.ts +++ b/src/lib/search-scope.ts @@ -22,6 +22,8 @@ const labelTypes = [ const sourceStatusValues = ["current", "review_due", "outdated", "unknown"] as const; const validationStatusValues = ["unverified", "locally_reviewed", "approved"] as const; const documentScopeQueryPageSize = 1000; +const labelScopeDocumentBatchSize = 200; +const labelScopeQueryPageSize = 1000; export const searchScopeFiltersSchema = z .object({ @@ -78,6 +80,7 @@ type ScopeDocumentRow = { }; type ScopeLabelRow = { + id?: string; document_id: string; label: string; label_type: DocumentLabelType; @@ -163,6 +166,39 @@ function labelMatches(labels: ScopeLabelRow[], type: DocumentLabelType, requeste return labels.some((label) => label.label_type === type && wanted.has(normalizeFilterText(label.label))); } +async function loadScopeLabels(args: { + supabase: SupabaseClient; + candidateIds: string[]; + signal?: AbortSignal; +}): Promise { + const rows: ScopeLabelRow[] = []; + + for (let start = 0; start < args.candidateIds.length; start += labelScopeDocumentBatchSize) { + const documentIdBatch = args.candidateIds.slice(start, start + labelScopeDocumentBatchSize); + for (let offset = 0; ; offset += labelScopeQueryPageSize) { + let labelQuery = args.supabase + .from("document_labels") + .select("id,document_id,label,label_type") + .in("document_id", documentIdBatch) + .in("label_type", [...labelTypes]) + .order("document_id", { ascending: true }) + .order("label_type", { ascending: true }) + .order("label", { ascending: true }) + .order("id", { ascending: true }) + .range(offset, offset + labelScopeQueryPageSize - 1); + if (args.signal) labelQuery = labelQuery.abortSignal(args.signal); + + const { data, error } = await labelQuery; + if (error) throw new Error(error.message); + const page = (data ?? []) as ScopeLabelRow[]; + rows.push(...page); + if (page.length < labelScopeQueryPageSize) break; + } + } + + return rows; +} + function isLocalSource(metadata: ClinicalSourceMetadata) { const jurisdiction = `${metadata.jurisdiction ?? ""} ${metadata.publisher ?? ""}`.toLowerCase(); return /\b(?:wa|western australia|north metropolitan|east metropolitan|south metropolitan|perth|health service)\b/.test( @@ -326,14 +362,9 @@ export async function resolveSearchScope(args: { hasValues(filters.labelTypesAny); let labelsByDocument = new Map(); if (needsLabels) { - const { data: labelRows, error: labelError } = await args.supabase - .from("document_labels") - .select("document_id,label,label_type") - .in("document_id", candidateIds) - .in("label_type", [...labelTypes]); - if (labelError) throw new Error(labelError.message); + const labelRows = await loadScopeLabels({ supabase: args.supabase, candidateIds, signal: args.signal }); labelsByDocument = new Map(); - for (const label of (labelRows ?? []) as ScopeLabelRow[]) { + for (const label of labelRows) { labelsByDocument.set(label.document_id, [...(labelsByDocument.get(label.document_id) ?? []), label]); } } diff --git a/src/lib/service-catalog-mapper.ts b/src/lib/service-catalog-mapper.ts index f3f0802cc..185677cc0 100644 --- a/src/lib/service-catalog-mapper.ts +++ b/src/lib/service-catalog-mapper.ts @@ -1,5 +1,6 @@ import type { CatalogService } from "@/lib/service-catalog"; import { catalogServiceSlug, extractEmails, extractPhones, splitReferralLines } from "@/lib/service-catalog"; +import { compactBestUseTitle } from "@/lib/compact-best-use-title"; import type { ServiceChipTone, ServiceContact, @@ -170,12 +171,34 @@ function buildSummaryCards(service: CatalogService): ServiceSummaryCard[] { }); } - if (service.confidence || service.verification_flags.length > 0) { + const bestUse = cleanField(service.best_use_indication) ?? cleanField(service.discharge_planning_usefulness); + if (bestUse) { +<<<<<<< ours +<<<<<<< ours cards.push({ - id: "confirm", - label: "Confirm", - title: service.verification_flags[0] ?? `${service.confidence} confidence`, - detail: "Verify locally before clinical use", + id: "best-use", + label: "Best use", + title: bestUse, + detail: cleanField(service.patient_group) ?? cleanField(service.sections[0]) ?? "Clinical fit and referral priority", +======= + const title = compactBestUseTitle(bestUse); + cards.push({ + id: "best-use", + label: "Best use", +======= + const title = compactBestUseTitle(bestUse); + cards.push({ + id: "best-use", + label: "Best use", +>>>>>>> theirs + title, + detail: + compactBestUseTitle(cleanField(service.patient_group) ?? cleanField(service.sections[0]) ?? "") || + (title === bestUse ? "Clinical fit and referral priority" : bestUse), +<<<<<<< ours +>>>>>>> theirs +======= +>>>>>>> theirs }); } diff --git a/src/lib/source-authority-metadata.ts b/src/lib/source-authority-metadata.ts index dd7de3ad5..324216268 100644 --- a/src/lib/source-authority-metadata.ts +++ b/src/lib/source-authority-metadata.ts @@ -266,6 +266,25 @@ export function auditSourceAuthorityDocuments(documents: SourceAuthorityDocument const proposedCorrections = analyses.filter( ({ analysis }) => !analysis.unresolvedConflict && analysis.changedKeys.length > 0, ); + const designationCounts = analyses.reduce>( + (counts, { document, analysis }) => { + const metadata = analysis.changes.publisher_code + ? { ...metadataRecord(document.metadata), publisher_code: analysis.changes.publisher_code } + : document.metadata; + const designation = + analysis.excludedReason === "registry_record" ? "unclassified" : classifySourceAuthority(metadata).designation; + counts[designation] = (counts[designation] ?? 0) + 1; + return counts; + }, + { official: 0, trusted: 0, unclassified: 0 }, + ); + const unclassifiedSamples = analyses + .filter( + ({ document, analysis }) => + analysis.excludedReason === "registry_record" || + classifySourceAuthority(document.metadata).designation === "unclassified", + ) + .slice(0, 20); const conflictReasonCounts = conflicts.reduce>((counts, { analysis }) => { for (const conflict of analysis.conflicts) counts[conflict] = (counts[conflict] ?? 0) + 1; return counts; @@ -277,6 +296,8 @@ export function auditSourceAuthorityDocuments(documents: SourceAuthorityDocument australian_authority_candidates: australianCandidates.length, international_authority_documents: recognized.length - australianCandidates.length, excluded_registry_record_count: excludedRegistryRecords.length, + designation_counts: designationCounts, + unclassified_sample_count: unclassifiedSamples.length, authority_conflict_count: conflicts.length, authority_conflict_reason_counts: Object.fromEntries( Object.entries(conflictReasonCounts).sort(([left], [right]) => left.localeCompare(right)), @@ -285,6 +306,9 @@ export function auditSourceAuthorityDocuments(documents: SourceAuthorityDocument missing_australian_locality_count: missingLocality.length, proposed_locality_correction_count: proposedCorrections.length, passed: conflicts.length === 0 && missingLocality.length === 0, + unclassified_samples: unclassifiedSamples.map(({ document, analysis }) => + compactAuthorityDocument(document, analysis), + ), conflicts: conflicts.map(({ document, analysis }) => ({ ...compactAuthorityDocument(document, analysis), conflicts: analysis.conflicts, diff --git a/src/lib/source-authority-registry.ts b/src/lib/source-authority-registry.ts index 21f9a6dcd..b849a5dd8 100644 --- a/src/lib/source-authority-registry.ts +++ b/src/lib/source-authority-registry.ts @@ -1,7 +1,8 @@ -import { normalizeSourceMetadata } from "@/lib/source-metadata"; import type { ClinicalSourceMetadata } from "@/lib/types"; export type AustralianSourceTier = "wa_validated" | "australian_national" | "australian_state" | "supplementary"; +export type SourceDesignation = "official" | "trusted" | "unclassified"; +export type SourceOfficialBasis = "wa_hospital" | "wa_health_service_network" | null; export type SourceAuthorityScope = "wa" | "australian_national" | "australian_state" | "international"; @@ -13,12 +14,26 @@ export type SourceAuthorityDefinition = { jurisdictions: readonly string[]; scope: SourceAuthorityScope; tier: Exclude | "supplementary"; + designation: SourceDesignation; + officialBasis: SourceOfficialBasis; }; export type SourceAuthorityConflict = "publisher_mismatch" | "jurisdiction_mismatch"; +export type SourceDesignationReasonCode = + | "recognized_official_wa_hospital" + | "recognized_official_wa_health_service_network" + | "recognized_trusted_authority" + | "registry_summary_identity" + | "unrecognized_authority" + | "publisher_alias_requires_jurisdiction" + | "authority_metadata_conflict"; export type SourceAuthorityClassification = { tier: AustralianSourceTier; + designation: SourceDesignation; + authorityKey: string | null; + officialBasis: SourceOfficialBasis; + reasonCodes: SourceDesignationReasonCode[]; authorityTier: SourceAuthorityDefinition["tier"] | null; authority: SourceAuthorityDefinition | null; matchedBy: "publisher_code" | "publisher_alias" | "none"; @@ -28,6 +43,18 @@ export type SourceAuthorityClassification = { eligibilityReasons: string[]; }; +function sourceMetadataRecord(input: unknown): Partial { + return input && typeof input === "object" && !Array.isArray(input) ? (input as Partial) : {}; +} + +function sourceMetadataString(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function sourceMetadataStatus(value: unknown, fallback: T): T { + return typeof value === "string" && value.trim() ? (value as T) : fallback; +} + const waJurisdictions = ["Australia/WA", "Australia/Western Australia", "Western Australia", "WA"] as const; const nationalJurisdictions = [ "Australia", @@ -38,9 +65,15 @@ const nationalJurisdictions = [ ] as const; function authority( - definition: Omit & { publisherAliases?: readonly string[] }, + definition: Omit & { + publisherAliases?: readonly string[]; + designation?: SourceDesignation; + officialBasis?: SourceOfficialBasis; + }, ): SourceAuthorityDefinition { return { + designation: "trusted", + officialBasis: null, ...definition, publisherAliases: [definition.publisher, ...(definition.publisherAliases ?? [])], }; @@ -74,6 +107,19 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_hospital", + }), + authority({ + key: "child-and-adolescent-health-service", + codes: ["CAHS"], + publisher: "Child and Adolescent Health Service", + publisherAliases: ["Perth Children's Hospital", "Princess Margaret Hospital"], + jurisdictions: waJurisdictions, + scope: "wa", + tier: "wa_validated", + designation: "official", + officialBasis: "wa_health_service_network", }), authority({ key: "camhs-wa", @@ -91,6 +137,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_health_service_network", }), authority({ key: "fiona-stanley-fremantle-hospitals-group", @@ -100,6 +148,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_hospital", }), authority({ key: "king-edward-memorial-hospital", @@ -108,6 +158,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_hospital", }), authority({ key: "north-metropolitan-health-service", @@ -116,6 +168,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_health_service_network", }), authority({ key: "peel-health-campus", @@ -124,6 +178,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_hospital", }), authority({ key: "rockingham-peel-group", @@ -132,6 +188,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_hospital", }), authority({ key: "royal-perth-bentley-group", @@ -140,6 +198,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_hospital", }), authority({ key: "south-metropolitan-health-service", @@ -148,6 +208,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_health_service_network", }), authority({ key: "wa-country-health-service", @@ -157,6 +219,8 @@ export const sourceAuthorityRegistry = [ jurisdictions: waJurisdictions, scope: "wa", tier: "wa_validated", + designation: "official", + officialBasis: "wa_health_service_network", }), authority({ key: "acsqhc", @@ -324,7 +388,9 @@ function jurisdictionCompatible(authorityEntry: SourceAuthorityDefinition, juris ); } -function isCurrentUsableDocument(metadata: ClinicalSourceMetadata) { +function isCurrentUsableDocument( + metadata: Pick, +) { return ( metadata.source_kind !== "registry_record" && metadata.document_status === "current" && @@ -332,14 +398,23 @@ function isCurrentUsableDocument(metadata: ClinicalSourceMetadata) { ); } -function isLocallyValidated(metadata: ClinicalSourceMetadata) { +function isLocallyValidated(metadata: Pick) { return ( metadata.clinical_validation_status === "approved" || metadata.clinical_validation_status === "locally_reviewed" ); } export function classifySourceAuthority(input: unknown): SourceAuthorityClassification { - const metadata = normalizeSourceMetadata(input); + const rawMetadata = sourceMetadataRecord(input); + const metadata = { + source_kind: sourceMetadataString(rawMetadata.source_kind), + publisher: sourceMetadataString(rawMetadata.publisher), + publisher_code: sourceMetadataString(rawMetadata.publisher_code), + jurisdiction: sourceMetadataString(rawMetadata.jurisdiction), + document_status: sourceMetadataStatus(rawMetadata.document_status, "unknown"), + clinical_validation_status: sourceMetadataStatus(rawMetadata.clinical_validation_status, "unverified"), + extraction_quality: sourceMetadataStatus(rawMetadata.extraction_quality, "unknown"), + } satisfies Partial; const code = normalizePublisherCode(metadata.publisher_code); const codeAuthority = sourceAuthorityForPublisherCode(code); const publisherAuthority = sourceAuthorityForPublisher(metadata.publisher); @@ -355,11 +430,21 @@ export function classifySourceAuthority(input: unknown): SourceAuthorityClassifi conflicts.push("jurisdiction_mismatch"); } - if (!authorityEntry) eligibilityReasons.push("unrecognized_authority"); + const reasonCodes: SourceDesignationReasonCode[] = []; + + if (metadata.source_kind === "registry_record") reasonCodes.push("registry_summary_identity"); + if (!authorityEntry) { + eligibilityReasons.push("unrecognized_authority"); + reasonCodes.push("unrecognized_authority"); + } if (!codeAuthority && publisherAuthority && !metadata.jurisdiction) { eligibilityReasons.push("publisher_alias_requires_jurisdiction"); + reasonCodes.push("publisher_alias_requires_jurisdiction"); + } + if (conflicts.length > 0) { + eligibilityReasons.push("authority_metadata_conflict"); + reasonCodes.push("authority_metadata_conflict"); } - if (conflicts.length > 0) eligibilityReasons.push("authority_metadata_conflict"); if (!isCurrentUsableDocument(metadata)) eligibilityReasons.push("source_not_current_usable_document"); if (authorityEntry?.tier === "wa_validated" && !isLocallyValidated(metadata)) { eligibilityReasons.push("wa_source_not_locally_validated"); @@ -372,8 +457,26 @@ export function classifySourceAuthority(input: unknown): SourceAuthorityClassifi isCurrentUsableDocument(metadata) && (authorityEntry?.tier !== "wa_validated" || isLocallyValidated(metadata)); + const designationRecognized = + Boolean(authorityEntry) && + metadata.source_kind !== "registry_record" && + conflicts.length === 0 && + (Boolean(codeAuthority) || Boolean(metadata.jurisdiction)); + const designation = designationRecognized ? (authorityEntry?.designation ?? "trusted") : "unclassified"; + if (designation === "official" && authorityEntry?.officialBasis === "wa_hospital") { + reasonCodes.push("recognized_official_wa_hospital"); + } else if (designation === "official" && authorityEntry?.officialBasis === "wa_health_service_network") { + reasonCodes.push("recognized_official_wa_health_service_network"); + } else if (designation === "trusted") { + reasonCodes.push("recognized_trusted_authority"); + } + return { tier: eligible ? (authorityEntry?.tier ?? "supplementary") : "supplementary", + designation, + authorityKey: designationRecognized ? (authorityEntry?.key ?? null) : null, + officialBasis: designationRecognized ? (authorityEntry?.officialBasis ?? null) : null, + reasonCodes: [...new Set(reasonCodes)], authorityTier: authorityEntry?.tier ?? null, authority: authorityEntry ?? null, matchedBy, diff --git a/src/lib/source-metadata.ts b/src/lib/source-metadata.ts index aaa074962..67d733239 100644 --- a/src/lib/source-metadata.ts +++ b/src/lib/source-metadata.ts @@ -1,4 +1,5 @@ import { logger } from "@/lib/logger"; +import { classifySourceAuthority, type SourceDesignation } from "@/lib/source-authority-registry"; import type { ClinicalSourceMetadata } from "@/lib/types"; const knownStatuses = new Set(["current", "review_due", "outdated", "unknown"]); @@ -117,9 +118,34 @@ export function clipboardProvenanceLine(metadata?: ClinicalSourceMetadata | null // clipboard line is an audit artifact, unlike the visible summary above // which drops unknown filler segments for readability. return [ + `Designation: ${sourceDesignationSummary(source)}`, `Review status: ${sourceStatusLabel(source)}`, `Validation: ${validationStatusLabel(source)}`, `Review date: ${formatClinicalDate(source.review_date)}`, `Jurisdiction: ${source.jurisdiction ?? "Unknown"}`, ].join(" | "); } + +export function sourceDesignationLabel(designation: SourceDesignation) { + if (designation === "official") return "Official"; + if (designation === "trusted") return "Trusted"; + return "Unclassified"; +} + +export function sourceDesignationDescription(metadata?: ClinicalSourceMetadata | null) { + const classification = classifySourceAuthority(metadata); + if (classification.designation === "official") { + return classification.officialBasis === "wa_hospital" + ? "Authenticated source issued by a recognised WA hospital. Official does not imply current, locally approved, or clinically relevant." + : "Authenticated source issued by a recognised WA health-service network. Official does not imply current, locally approved, or clinically relevant."; + } + if (classification.designation === "trusted") { + return "Recognised authority outside the Official WA hospital/network scope. Trusted does not imply current, locally approved, or clinically relevant."; + } + return "Source authority is unknown, ambiguous, conflicting, or a registry summary. Treat as unclassified provenance."; +} + +export function sourceDesignationSummary(metadata?: ClinicalSourceMetadata | null) { + const classification = classifySourceAuthority(metadata); + return sourceDesignationLabel(classification.designation); +} diff --git a/src/lib/supabase/database.types.ts b/src/lib/supabase/database.types.ts index 58e20c4de..9f0250669 100644 --- a/src/lib/supabase/database.types.ts +++ b/src/lib/supabase/database.types.ts @@ -2306,6 +2306,13 @@ export type Database = { }; Returns: Json; }; + create_uploaded_document_with_ingestion_job: { + Args: { + p_document: Json; + p_max_attempts: number; + }; + Returns: Json; + }; complete_strict_enrichment_job: { Args: { p_agent_version?: string; diff --git a/src/lib/validation/answer-request.ts b/src/lib/validation/answer-request.ts index c3805af7e..2a2fbbb49 100644 --- a/src/lib/validation/answer-request.ts +++ b/src/lib/validation/answer-request.ts @@ -19,6 +19,20 @@ export const answerRequestSchema = z message: "Document summary mode requires a document id.", }); } + if (value.summaryMode && value.documentId && value.documentIds?.some((id) => id !== value.documentId)) { + context.addIssue({ + code: "custom", + path: ["documentIds"], + message: "Document summary mode only supports the selected document id.", + }); + } + if (value.summaryMode && value.documentIds && value.documentIds.length > 1) { + context.addIssue({ + code: "custom", + path: ["documentIds"], + message: "Document summary mode only supports one document id.", + }); + } }); export type AnswerRequestBody = z.infer; diff --git a/supabase/drift-manifest.json b/supabase/drift-manifest.json index b515a8845..cf17e3e01 100644 --- a/supabase/drift-manifest.json +++ b/supabase/drift-manifest.json @@ -2,7 +2,7 @@ "generated_at": "2026-07-23T23:41:25.333Z", "generator": "scripts/generate-drift-manifest.ts", "postgres_image": "supabase/postgres:17.6.1.127", - "schema_sha256": "b00b143ba74e98525ba17f032245c05a7befff9adbc07c612f0f8de29855bc51", + "schema_sha256": "19b45be58ad5999f6df80429cd66097e14ab8ad727cc90cd97f3917b0f9e8d6c", "replay_seconds": 16, "snapshot": { "views": [ diff --git a/supabase/migrations/20260724120000_create_uploaded_document_with_ingestion_job.sql b/supabase/migrations/20260724120000_create_uploaded_document_with_ingestion_job.sql new file mode 100644 index 000000000..7fccc8ad8 --- /dev/null +++ b/supabase/migrations/20260724120000_create_uploaded_document_with_ingestion_job.sql @@ -0,0 +1,70 @@ +-- Atomically create an uploaded document row and its initial ingestion job. +-- The upload route has already stored the object and validated owner/file metadata; +-- this RPC closes the process-crash window between `documents.insert` and +-- `ingestion_jobs.insert` by doing both database writes in one transaction. +create or replace function public.create_uploaded_document_with_ingestion_job( + p_document jsonb, + p_max_attempts integer +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_document public.documents%rowtype; + v_job public.ingestion_jobs%rowtype; +begin + insert into public.documents ( + id, + owner_id, + title, + description, + file_name, + file_type, + file_size, + storage_path, + content_hash, + status, + metadata + ) values ( + (p_document->>'id')::uuid, + (p_document->>'owner_id')::uuid, + p_document->>'title', + nullif(p_document->>'description', ''), + p_document->>'file_name', + p_document->>'file_type', + coalesce((p_document->>'file_size')::bigint, 0), + p_document->>'storage_path', + nullif(p_document->>'content_hash', ''), + 'queued', + coalesce(p_document->'metadata', '{}'::jsonb) + ) + returning * into v_document; + + insert into public.ingestion_jobs ( + document_id, + batch_id, + status, + stage, + progress, + max_attempts + ) values ( + v_document.id, + null, + 'pending', + 'queued', + 0, + p_max_attempts + ) + returning * into v_job; + + return jsonb_build_object( + 'document', to_jsonb(v_document), + 'job', to_jsonb(v_job) + ); +end; +$$; + +revoke execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) from public, anon, authenticated; +grant execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) to service_role; diff --git a/supabase/schema.sql b/supabase/schema.sql index e7f35428f..99aee2ec6 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -5901,6 +5901,78 @@ revoke execute on function public.request_indexing_v3_enrichment(uuid, uuid) fro grant execute on function public.request_indexing_v3_enrichment(uuid, uuid) to service_role; +-- Atomically create an uploaded document row and its initial ingestion job. +-- The upload route has already stored the object and validated owner/file metadata; +-- this RPC closes the process-crash window between `documents.insert` and +-- `ingestion_jobs.insert` by doing both database writes in one transaction. +create or replace function public.create_uploaded_document_with_ingestion_job( + p_document jsonb, + p_max_attempts integer +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_document public.documents%rowtype; + v_job public.ingestion_jobs%rowtype; +begin + insert into public.documents ( + id, + owner_id, + title, + description, + file_name, + file_type, + file_size, + storage_path, + content_hash, + status, + metadata + ) values ( + (p_document->>'id')::uuid, + (p_document->>'owner_id')::uuid, + p_document->>'title', + nullif(p_document->>'description', ''), + p_document->>'file_name', + p_document->>'file_type', + coalesce((p_document->>'file_size')::bigint, 0), + p_document->>'storage_path', + nullif(p_document->>'content_hash', ''), + 'queued', + coalesce(p_document->'metadata', '{}'::jsonb) + ) + returning * into v_document; + + insert into public.ingestion_jobs ( + document_id, + batch_id, + status, + stage, + progress, + max_attempts + ) values ( + v_document.id, + null, + 'pending', + 'queued', + 0, + p_max_attempts + ) + returning * into v_job; + + return jsonb_build_object( + 'document', to_jsonb(v_document), + 'job', to_jsonb(v_job) + ); +end; +$$; + +revoke execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) from public, anon, authenticated; +grant execute on function public.create_uploaded_document_with_ingestion_job(jsonb, integer) to service_role; + + create table if not exists public.rag_visual_eval_cases ( id uuid primary key default gen_random_uuid(), owner_id uuid references auth.users(id) on delete set null, diff --git a/tests/accessible-table.dom.test.tsx b/tests/accessible-table.dom.test.tsx index e39ffc369..be4a609b9 100644 --- a/tests/accessible-table.dom.test.tsx +++ b/tests/accessible-table.dom.test.tsx @@ -48,4 +48,19 @@ describe("AccessibleTable (jsdom)", () => { expect(expandButton).toHaveAttribute("aria-expanded", "true"); expect(screen.getByTestId("table-fullscreen-dialog")).toBeInTheDocument(); }); + + it("shows the provided source-image fallback for low-confidence clinical tables", () => { + render( + Original table image
} + />, + ); + + expect(screen.getByTestId("table-low-confidence-note")).toHaveTextContent("showing the source document image"); + expect(screen.getByTestId("source-table-image")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); }); diff --git a/tests/answer-render-policy.test.ts b/tests/answer-render-policy.test.ts index 917b7db36..8545bf87c 100644 --- a/tests/answer-render-policy.test.ts +++ b/tests/answer-render-policy.test.ts @@ -120,6 +120,17 @@ function answer(overrides: Partial = {}): RagAnswer { image_count: 1, viewer_href: "/documents/doc-1?page=4&chunk=chunk-1", } satisfies BestSourceRecommendation, + relevance: { + verdict: "direct", + label: "Direct source support", + matchedTerms: ["clozapine", "withhold"], + missingTerms: [], + directSourceCount: 1, + weakSourceCount: 0, + score: 1, + supportReason: "Direct source support.", + isSourceBacked: true, + }, ...overrides, }; } @@ -454,6 +465,21 @@ describe("answer render policy", () => { expect(model.copyText).toContain("within 24 hours"); }); + it("caps missing relevance metadata below source-backed trust", () => { + const model = buildAnswerRenderModel( + answer({ + relevance: undefined, + smartPanel: undefined, + }), + ); + + expect(model.trust).toBe("low"); + expect(model.allowedBlocks).toEqual(expect.arrayContaining(["sourceStatus", "reviewSources"])); + expect(model.allowedBlocks).not.toContain("evidenceMap"); + expect(model.allowedBlocks).not.toContain("quoteCards"); + expect(model.allowedBlocks).not.toContain("relatedDocuments"); + }); + it("limits unsupported answers to source review and warnings even when raw extras are present", () => { const model = buildAnswerRenderModel( answer({ diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts index b8ac0ced9..c66f3027d 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -123,7 +123,7 @@ describe("content and services audit regressions", () => { expect(formDetailSource).not.toMatch(/\b\d+\s+pages?\b|\bReview due\b/i); expect(formDetailSource).not.toContain("01 May 2026"); expect(formDetailSource).not.toMatch(/5\(2\)|Admission order|Treatment order/); - expect(formDetailSource).toContain("Full pathway unavailable"); + expect(formDetailSource).toContain("Open official source / pathway"); expect(normalizedFormDetailSource).toContain( 'label: "Source currency", value: displayText(form.source?.reviewed, "Review locally")', ); diff --git a/tests/clinical-dashboard-merge-artifacts.test.ts b/tests/clinical-dashboard-merge-artifacts.test.ts index e0473ad07..ec7ec4bdb 100644 --- a/tests/clinical-dashboard-merge-artifacts.test.ts +++ b/tests/clinical-dashboard-merge-artifacts.test.ts @@ -115,7 +115,7 @@ describe("ClinicalDashboard merge-artifact guards", () => { }); it("releases the Safari toolbar reserve only after phone composers hide", () => { - expect(mobileComposerReserveSource).toContain('export const mobileComposerHiddenReserve = "0.75rem"'); + expect(mobileComposerReserveSource).toContain('export const mobileComposerHiddenReserve = "0rem"'); expect(mobileComposerReserveSource).toContain( 'export const mobileComposerDifferentialsCompareReserve = "calc(12.5rem + var(--safe-area-bottom))"', ); @@ -150,7 +150,7 @@ describe("ClinicalDashboard merge-artifact guards", () => { ); expect(documentViewerSource).toContain('data-testid="document-viewer-content"'); - expect(documentViewerSource).toContain('"max-sm:pb-3"'); + expect(documentViewerSource).toContain('"max-sm:pb-0"'); expect(documentViewerSource).toContain('"max-sm:pb-[calc(9rem+var(--safe-area-bottom))]"'); // Hidden document content must not reintroduce Safari toolbar inset padding. expect(documentViewerSource).not.toMatch(/composerScrollHidden\s*\?\s*["']max-sm:pb-\[calc\([^"']*safe-area/); @@ -175,7 +175,7 @@ describe("ClinicalDashboard merge-artifact guards", () => { expect(formulationUiSource).not.toContain("pb-[calc(7rem+env(safe-area-inset-bottom))]"); expect(formulationUiSource).toContain("max-sm:min-h-0"); expect(favouritesLibrarySource).not.toContain("pb-[calc(6rem+env(safe-area-inset-bottom))]"); - expect(globalStylesSource).toContain("--phone-dock-hidden-pad: 0.75rem"); + expect(globalStylesSource).toContain("--phone-dock-hidden-pad: 0rem"); }); it("does not reintroduce the obsolete output-mode copy helper", () => { diff --git a/tests/document-detail-performance.test.ts b/tests/document-detail-performance.test.ts index b3840f113..e47a30988 100644 --- a/tests/document-detail-performance.test.ts +++ b/tests/document-detail-performance.test.ts @@ -62,6 +62,10 @@ describe("document detail loading contract", () => { expect(loader).toContain("map(withTableFactReviewMetadata)"); expect(loader).toContain("map(withDocumentLabelReviewMetadata)"); expect(loader).toContain("isHiddenDocumentLabel"); + expect(loader).toContain('metadataNumber(metadata, "row_count")'); + expect(loader).toContain('metadataBoolean(metadata, "rows_truncated")'); + expect(loader).toContain('metadataNumber(metadata, "crop_completeness")'); + expect(loader).toContain('metadataNumber(metadata, "structured_extraction_confidence")'); }); it("returns explicit demo, scope, and request-window metadata", () => { diff --git a/tests/favourites-auth-gate.test.ts b/tests/favourites-auth-gate.test.ts index 708ff637c..fc23954e5 100644 --- a/tests/favourites-auth-gate.test.ts +++ b/tests/favourites-auth-gate.test.ts @@ -28,7 +28,18 @@ describe("favourites auth gate", () => { expect(sidebar).toContain("Your library"); expect(sidebar).toContain('aria-label="Your library"'); expect(sidebar).toContain("showAccountLibrary"); + const primarySidebarToolIdsInitializer = sidebar.match( + /const primarySidebarToolIds = new Set<[^>]+>\(\[([\s\S]*?)\]\);/, + )?.[1]; + expect(primarySidebarToolIdsInitializer).toBeDefined(); + expect(primarySidebarToolIdsInitializer).toContain('"therapy-compass"'); expect(sidebar).not.toMatch(/const sidebarToolItems = \[[\s\S]*\{ id: "favourites", label: "Favourites"/); + expect(primarySidebarToolIdsInitializer).not.toContain('"differentials"'); + expect(primarySidebarToolIdsInitializer).not.toContain('"dsm"'); + expect(primarySidebarToolIdsInitializer).not.toContain('"specifiers"'); + expect(primarySidebarToolIdsInitializer).not.toContain('"formulation"'); + expect(primarySidebarToolIdsInitializer).not.toContain('"prescribing"'); + expect(primarySidebarToolIdsInitializer).not.toContain('"factsheets"'); expect(shell).toContain("showAccountLibrary={favouritesAccessible}"); expect(shell).toContain("canAccessFavourites={favouritesAccessible}"); diff --git a/tests/favourites-hub-unavailable-controls.dom.test.tsx b/tests/favourites-hub-unavailable-controls.dom.test.tsx index 0bb48ecf4..9ee196e58 100644 --- a/tests/favourites-hub-unavailable-controls.dom.test.tsx +++ b/tests/favourites-hub-unavailable-controls.dom.test.tsx @@ -8,26 +8,21 @@ vi.mock("@/components/clinical-dashboard/use-saved-registry-favourites", () => ( })); describe("FavouritesHub unavailable controls", () => { - it("keeps unavailable actions focusable and exposes their reasons", () => { + it("keeps unavailable actions natively disabled and exposes their reasons", () => { render( undefined} demoMode={false} />); const recent = screen.getByRole("button", { name: "Recent" }); const add = screen.getByRole("button", { name: /Add favourite/ }); const newSet = screen.getByRole("button", { name: "New set" }); - expect(recent).toBeEnabled(); - expect(recent).toHaveAttribute("aria-disabled", "true"); + expect(recent).toBeDisabled(); + expect(recent).not.toHaveAttribute("aria-disabled"); expect(recent).toHaveAccessibleDescription("Additional sort options are coming soon."); - expect(add).toBeEnabled(); - expect(add).toHaveAttribute("aria-disabled", "true"); + expect(add).toBeDisabled(); + expect(add).not.toHaveAttribute("aria-disabled"); expect(add).toHaveAccessibleDescription("Adding favourites from this screen is coming soon."); - expect(newSet).toBeEnabled(); - expect(newSet).toHaveAttribute("aria-disabled", "true"); + expect(newSet).toBeDisabled(); + expect(newSet).not.toHaveAttribute("aria-disabled"); expect(newSet).toHaveAccessibleDescription("Creating favourite sets is coming soon."); - - for (const control of [recent, add, newSet]) { - control.focus(); - expect(document.activeElement).toBe(control); - } }); }); diff --git a/tests/formatting-fixture-audit.test.ts b/tests/formatting-fixture-audit.test.ts new file mode 100644 index 000000000..d1072dc18 --- /dev/null +++ b/tests/formatting-fixture-audit.test.ts @@ -0,0 +1,43 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; + +describe("formatting fixture audit", () => { + it("reports page-level formatting risks from extractor JSON without provider access", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "formatting-audit-")); + const fixture = path.join(dir, "extract.json"); + await writeFile( + fixture, + JSON.stringify({ + pages: [{ pageNumber: 2, needsOcr: true }], + images: [ + { + sourceKind: "table_crop", + width: 1600, + height: 260, + metadata: { + crop_completeness: 0.72, + rows_truncated: true, + structured_extraction_confidence: 0.42, + ocr_text_density: 0.1, + }, + }, + ], + }), + "utf8", + ); + + const result = spawnSync("node", ["scripts/run-tsx.mjs", "scripts/audit-formatting-fixtures.ts", fixture], { + cwd: process.cwd(), + encoding: "utf8", + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("table rows truncated"); + expect(result.stdout).toContain("crop cut-off risk"); + expect(result.stdout).toContain("extreme image aspect ratio"); + expect(result.stdout).toContain("page needs OCR"); + }); +}); diff --git a/tests/forms-back-navigation.dom.test.tsx b/tests/forms-back-navigation.dom.test.tsx index a668315c4..c72c8c7fc 100644 --- a/tests/forms-back-navigation.dom.test.tsx +++ b/tests/forms-back-navigation.dom.test.tsx @@ -1,18 +1,9 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { FormDetailPage } from "@/components/forms/form-detail-page"; import { formRecords } from "@/lib/forms"; -const router = vi.hoisted(() => ({ - back: vi.fn(), - push: vi.fn(), -})); - -vi.mock("next/navigation", () => ({ - useRouter: () => router, -})); - vi.mock("@/components/account-data-provider", () => ({ useAccountData: () => ({ isSaved: () => false, @@ -20,22 +11,11 @@ vi.mock("@/components/account-data-provider", () => ({ }), })); -beforeEach(() => { - router.back.mockReset(); - router.push.mockReset(); -}); - describe("Form detail back navigation", () => { - it("uses the canonical Forms route even when browser history has prior entries", () => { - window.history.pushState({}, "", "/unrelated-route"); - window.history.pushState({}, "", "/forms/transport-crisis-form"); - expect(window.history.length).toBeGreaterThan(1); - + it("leaves the single back action to the shared information-page header", () => { render(); - fireEvent.click(screen.getByRole("button", { name: "Back to forms" })); - expect(router.push).toHaveBeenCalledOnce(); - expect(router.push).toHaveBeenCalledWith("/forms?focus=1"); - expect(router.back).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Back to forms" })).not.toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Form breadcrumbs" })).toBeInTheDocument(); }); }); diff --git a/tests/forms.test.ts b/tests/forms.test.ts index cbf77de7e..e6ab841b0 100644 --- a/tests/forms.test.ts +++ b/tests/forms.test.ts @@ -40,6 +40,8 @@ describe("psychiatry form records", () => { "4E", ]); expect(details.find((entry) => entry?.form === "13")?.availability).toBe("contact_ocp"); + expect(details.find((entry) => entry?.form === "1A")?.before).toEqual([]); + expect(details.find((entry) => entry?.form === "1A")?.parallel).toEqual(["1A attachment"]); const catalogueText = JSON.stringify(formRecords).toLowerCase(); diff --git a/tests/ingestion-mutation-safety.test.ts b/tests/ingestion-mutation-safety.test.ts index f5c3ca185..4e652cf2c 100644 --- a/tests/ingestion-mutation-safety.test.ts +++ b/tests/ingestion-mutation-safety.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildActiveJobsSafetyResult, isActiveAgentEnrichmentJob } from "../src/lib/ingestion-mutation-safety"; +import { + buildActiveJobsSafetyResult, + checkIngestionMutationSafety, + isActiveAgentEnrichmentJob, +} from "../src/lib/ingestion-mutation-safety"; describe("active enrichment-agent detection (R24d)", () => { const now = Date.parse("2026-07-07T00:00:00.000Z"); @@ -61,6 +65,87 @@ describe("active enrichment-agent detection (R24d)", () => { }); }); +describe("checkIngestionMutationSafety agent-enrichment guard", () => { + function supabaseMock(agentJobs: unknown[]) { + return { + from(table: string) { + if (table === "import_batches") { + return { + select: () => ({ + limit: async () => ({ error: null }), + }), + }; + } + if (table === "ingestion_jobs") { + return { + select: () => ({ + in: () => ({ + in: async () => ({ data: [], error: null }), + }), + }), + }; + } + if (table === "indexing_v3_agent_jobs") { + return { + select: () => ({ + in: () => ({ + eq: async () => ({ data: agentJobs, error: null }), + }), + }), + }; + } + throw new Error(`Unexpected table ${table}`); + }, + }; + } + + it("blocks full/retry mutations while a fresh agent-enrichment pass owns the document", async () => { + const result = await checkIngestionMutationSafety({ + supabase: supabaseMock([ + { + document_id: "doc-1", + status: "processing", + locked_at: "2026-07-07T23:50:00.000Z", + updated_at: "2026-07-07T23:50:00.000Z", + }, + ]) as never, + documentIds: ["doc-1"], + action: "Reindex", + checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: true, + staleAfterMinutes: 45, + now: new Date("2026-07-08T00:00:00.000Z"), + }); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected active agent-enrichment guard to block"); + expect(result.reason).toBe("active_agent_enrichment"); + expect(result.status).toBe(409); + expect(result.activeJobs[0]?.stage).toBe("agent_enrichment"); + }); + + it("does not query agent-enrichment jobs when the caller opts out", async () => { + const result = await checkIngestionMutationSafety({ + supabase: supabaseMock([ + { + document_id: "doc-1", + status: "processing", + locked_at: "2026-07-07T23:50:00.000Z", + updated_at: "2026-07-07T23:50:00.000Z", + }, + ]) as never, + documentIds: ["doc-1"], + action: "Enrichment", + checkActiveJobs: true, + checkActiveAgentEnrichmentJobs: false, + staleAfterMinutes: 45, + now: new Date("2026-07-08T00:00:00.000Z"), + }); + + expect(result.ok).toBe(true); + }); +}); + describe("buildActiveJobsSafetyResult (R17 — reindex route 23505 race handling)", () => { const checkedAt = "2026-07-08T00:00:00.000Z"; const staleAfterMinutes = 45; diff --git a/tests/mobile-composer-reserve.test.ts b/tests/mobile-composer-reserve.test.ts index 92afcc602..960d95a88 100644 --- a/tests/mobile-composer-reserve.test.ts +++ b/tests/mobile-composer-reserve.test.ts @@ -13,8 +13,9 @@ import { } from "@/components/clinical-dashboard/mobile-composer-reserve"; describe("mobile composer reserve contract", () => { - it("collapses to the hidden pad without Safari toolbar safe-area", () => { - expect(mobileComposerHiddenReserve).toBe("0.75rem"); + it("collapses to zero hidden pad without Safari toolbar safe-area", () => { + expect(mobileComposerHiddenReserve).toBe("0rem"); + expect(mobileComposerHiddenReserveRem).toBe(0); // The rem number feeds readChromeCollapseBudget's px math; it must stay // equal to the CSS string above or the collapse budget silently drifts. expect(`${mobileComposerHiddenReserveRem}rem`).toBe(mobileComposerHiddenReserve); diff --git a/tests/navigation-back-button.dom.test.tsx b/tests/navigation-back-button.dom.test.tsx new file mode 100644 index 000000000..ab7b1235f --- /dev/null +++ b/tests/navigation-back-button.dom.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NavigationBackButton } from "@/components/navigation-back-button"; + +const router = vi.hoisted(() => ({ + back: vi.fn(), + push: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => router, +})); + +beforeEach(() => { + router.back.mockReset(); + router.push.mockReset(); +}); + +describe("NavigationBackButton", () => { + it("uses the explicit in-app fallback even when browser history has prior entries", () => { + window.history.pushState({}, "", "/external-entry"); + window.history.pushState({}, "", "/privacy"); + expect(window.history.length).toBeGreaterThan(1); + + render(); + fireEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(router.push).toHaveBeenCalledOnce(); + expect(router.push).toHaveBeenCalledWith("/"); + expect(router.back).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/pdf-extraction-budget.test.ts b/tests/pdf-extraction-budget.test.ts index f802a8f79..ff7f978cd 100644 --- a/tests/pdf-extraction-budget.test.ts +++ b/tests/pdf-extraction-budget.test.ts @@ -121,7 +121,7 @@ describe("PDF extraction budgets", () => { ); await expect( - runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 1_000 }, fakeExtractor), + runPythonPdfExtractor(inputPath, outputDir, { ...PDF_EXTRACTION_BUDGET, totalTimeoutMs: 3_000 }, fakeExtractor), ).rejects.toMatchObject({ code: "PDF_EXTRACTION_DEADLINE_EXCEEDED" }); const childPid = Number(await readFile(childPidPath, "utf8")); @@ -131,6 +131,13 @@ describe("PDF extraction budgets", () => { while (Date.now() < deadline) { try { process.kill(childPid, 0); + if (process.platform === "linux") { + const stat = await readFile(`/proc/${childPid}/stat`, "utf8").catch(() => ""); + if (stat.split(" ")[2] === "Z") { + childIsAlive = false; + break; + } + } await new Promise((resolve) => setTimeout(resolve, 25)); } catch { childIsAlive = false; diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index 837b09944..9d64c5718 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; @@ -269,6 +269,64 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) { error: null, }; } + if (name === "create_uploaded_document_with_ingestion_job") { + const document = (args?.p_document ?? {}) as Record; + const documentCall: QueryCall = { + table: "documents", + operation: "insert", + orFilters: [], + filters: [], + inFilters: [], + overlapsFilters: [], + insertPayload: document, + maybeSingle: false, + single: true, + }; + calls.push(documentCall); + const documentResult = resolveWithDefaultScope(documentCall); + if (documentResult.error) return documentResult; + + const insertedDocument = { + ...document, + ...(typeof documentResult.data === "object" && documentResult.data ? documentResult.data : {}), + status: "queued", + page_count: 0, + chunk_count: 0, + image_count: 0, + created_at: "2026-07-24T00:00:00.000Z", + }; + const jobPayload = { + document_id: document.id, + batch_id: null, + status: "pending", + stage: "queued", + progress: 0, + max_attempts: args?.p_max_attempts, + }; + const jobCall: QueryCall = { + table: "ingestion_jobs", + operation: "insert", + orFilters: [], + filters: [], + inFilters: [], + overlapsFilters: [], + insertPayload: jobPayload, + maybeSingle: false, + single: true, + }; + calls.push(jobCall); + const jobResult = resolveWithDefaultScope(jobCall); + if (jobResult.error) return jobResult; + + return ok({ + document: insertedDocument, + job: { + id: "job-1", + ...jobPayload, + ...(typeof jobResult.data === "object" && jobResult.data ? jobResult.data : {}), + }, + }); + } return ok([]); }); const client = { @@ -1438,7 +1496,7 @@ describe("private document API access", () => { expect(client.storageMocks.upload).toHaveBeenCalledTimes(1); }); - it("cleans up document and storage when aborted after document insert", async () => { + it("cleans up document and storage when aborted after the atomic upload enqueue", async () => { const controller = new AbortController(); const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); @@ -1459,7 +1517,7 @@ describe("private document API access", () => { expect(response.status).toBe(499); expect(client.calls.filter((call) => call.table === "documents" && call.operation === "insert")).toHaveLength(1); - expect(client.calls.some((call) => call.table === "ingestion_jobs" && call.operation === "insert")).toBe(false); + expect(client.calls.some((call) => call.table === "ingestion_jobs" && call.operation === "insert")).toBe(true); expect(client.calls.filter((call) => call.table === "documents" && call.operation === "delete")).toHaveLength(1); expect(client.storageMocks.remove).toHaveBeenCalledTimes(1); }); @@ -2740,24 +2798,18 @@ describe("private document API access", () => { expect(response.status).toBe(500); expect(client.storageMocks.remove).toHaveBeenCalledWith([uploadPath]); - expect( - client.calls.some( - (call) => - call.table === "documents" && - call.operation === "delete" && - call.filters.some((filter) => filter.column === "id" && typeof filter.value === "string") && - call.filters.some((filter) => filter.column === "owner_id" && filter.value === userId), - ), - ).toBe(true); - }); - - it("still runs catch cleanup when upload cleanup calls return non-throwing errors", async () => { + expect(client.calls.some((call) => call.table === "documents" && call.operation === "delete")).toBe(false); + }); + + it("still runs catch cleanup when post-enqueue cleanup calls return non-throwing errors", async () => { + const controller = new AbortController(); const client = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "insert") { return ok({ id: documentId }); } if (call.table === "ingestion_jobs" && call.operation === "insert") { - return fail("job insert failed"); + controller.abort(); + return ok({ id: "job-1", document_id: documentId }); } if (call.table === "documents" && call.operation === "delete") { return fail("document cleanup returned error"); @@ -2777,13 +2829,14 @@ describe("private document API access", () => { authenticatedRequest("/api/upload", { method: "POST", body: formData, + signal: controller.signal, }), ); const uploadPath = client.storageMocks.upload.mock.calls[0]?.[0] as string; const documentDeletes = client.calls.filter((call) => call.table === "documents" && call.operation === "delete"); - expect(response.status).toBe(500); - expect(documentDeletes.length).toBeGreaterThanOrEqual(2); + expect(response.status).toBe(499); + expect(documentDeletes).toHaveLength(1); expect(client.storageMocks.remove).toHaveBeenCalledWith([uploadPath]); }); @@ -3921,6 +3974,45 @@ describe("private document API access", () => { expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); }); + it("logs failed search telemetry against the parsed query instead of an unknown placeholder", async () => { + vi.stubEnv("NODE_ENV", "production"); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [], + telemetry: { retrieval_strategy: "text_fast_path" }, + })); + const client = createSupabaseMock((call) => + call.table === "documents" && call.operation === "select" ? fail("Unregistered API key") : ok([]), + ); + mockRuntime(client, { searchChunksWithTelemetry }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + authenticatedRequest("/api/search", { + method: "POST", + body: JSON.stringify({ + query: "Clozapine monitoring", + includeRelatedDocuments: false, + filters: { sourceStatuses: ["current"] }, + }), + }), + ); + + expect(response.status).toBe(500); + await vi.waitFor(() => { + const insert = client.calls.find((call) => call.table === "rag_queries" && call.operation === "insert"); + expect(insert).toBeTruthy(); + const payload = insert?.insertPayload as Record; + const expectedHash = createHmac("sha256", "test-query-hash-secret-at-least-16-chars") + .update("clozapine monitoring") + .digest("hex"); + const unknownHash = createHmac("sha256", "test-query-hash-secret-at-least-16-chars") + .update("unknown") + .digest("hex"); + expect(payload.query).toBe(`redacted-query:${expectedHash}`); + expect(payload.query).not.toBe(`redacted-query:${unknownHash}`); + }); + }); + it("falls back to visible demo answers only outside production when Supabase rejects the API key", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Live answer", @@ -4391,6 +4483,32 @@ describe("private document API access", () => { expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); + it("rejects streamed document summaries with conflicting documentIds before provider work", async () => { + const summarizeDocument = vi.fn(); + const answerQuestionWithScope = vi.fn(); + const client = createSupabaseMock(); + mockRuntime(client, { summarizeDocument, answerQuestionWithScope }); + const { POST } = await import("../src/app/api/answer/stream/route"); + + const response = await POST( + authenticatedRequest("/api/answer/stream", { + method: "POST", + body: JSON.stringify({ + query: "Summarize this document for practical clinical use.", + documentId, + documentIds: [otherDocumentId], + summaryMode: true, + }), + }), + ); + + expect(response.status).toBe(400); + await expect(payload(response)).resolves.toMatchObject({ error: "Invalid answer request." }); + expect(client.rpc).not.toHaveBeenCalled(); + expect(summarizeDocument).not.toHaveBeenCalled(); + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); + it("rate limits streamed document summaries before provider work", async () => { const summarizeDocument = vi.fn(async () => ({ answer: "Expensive streamed summary.", @@ -4803,6 +4921,70 @@ describe("private document API access", () => { expectFeedbackTokenBoundToAnswer(body); }); +<<<<<<< ours + it("runs non-stream document summaries through the governed summary path", async () => { + const answerQuestionWithScope = vi.fn(); + const summarizeDocument = vi.fn(async () => ({ + answer: "Full non-stream document summary.", + grounded: true, + confidence: "high", + citations: [], + sources: [], + })); +======= + it("rejects non-stream document summaries before RAG/provider work", async () => { + const answerQuestionWithScope = vi.fn(); + const summarizeDocument = vi.fn(); +>>>>>>> theirs + const client = createSupabaseMock(); + mockRuntime(client, { answerQuestionWithScope, summarizeDocument }); + const { POST } = await import("../src/app/api/answer/route"); + + const response = await POST( + authenticatedRequest("/api/answer", { + method: "POST", + body: JSON.stringify({ + query: "Summarize this document for practical clinical use.", + documentId, + summaryMode: true, + }), + }), + ); +<<<<<<< ours + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body).toMatchObject({ + answer: "Full non-stream document summary.", + interactionId: expect.any(String), + feedbackToken: expect.any(String), + }); + expect(summarizeDocument).toHaveBeenCalledWith(documentId, userId, { + signal: expect.any(AbortSignal), + }); + expect(client.rpc).toHaveBeenCalledTimes(1); + expect(client.rpc).toHaveBeenCalledWith( + "consume_summary_rate_limits_atomic", + expect.objectContaining({ + p_owner_id: userId, + p_subject_key: null, + p_answer_limit: 30, + p_summary_limit: 12, + }), + ); +======= + + expect(response.status).toBe(400); + await expect(payload(response)).resolves.toMatchObject({ + error: "Document summaries require the streaming answer endpoint.", + code: "summary_mode_stream_required", + }); + expect(client.rpc).not.toHaveBeenCalled(); + expect(summarizeDocument).not.toHaveBeenCalled(); +>>>>>>> theirs + expect(answerQuestionWithScope).not.toHaveBeenCalled(); + }); + it("rate limits document summarization before OpenAI work", async () => { const summarizeDocument = vi.fn(async () => ({ summary: "Expensive summary" })); const client = createSupabaseMock(); diff --git a/tests/search-scope.test.ts b/tests/search-scope.test.ts index 49f2fb6aa..e2a8f16a4 100644 --- a/tests/search-scope.test.ts +++ b/tests/search-scope.test.ts @@ -1,6 +1,84 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { activeScopeFilterCount, resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; +type QueryCall = { + table: string; + selected?: string; + range?: { from: number; to: number }; + filters: Array<{ column: string; value: unknown }>; + inFilters: Array<{ column: string; values: unknown[] }>; + orders: string[]; + abortSignals: AbortSignal[]; +}; + +type QueryResult = { data: unknown[]; error: { message: string } | null }; +type QueryResolver = (call: QueryCall) => QueryResult; + +class QueryBuilder implements PromiseLike { + constructor( + private readonly call: QueryCall, + private readonly resolver: QueryResolver, + ) {} + + select(selected: string) { + this.call.selected = selected; + return this; + } + + eq(column: string, value: unknown) { + this.call.filters.push({ column, value }); + return this; + } + + is(column: string, value: unknown) { + this.call.filters.push({ column, value }); + return this; + } + + or() { + return this; + } + + in(column: string, values: unknown[]) { + this.call.inFilters.push({ column, values }); + return this; + } + + order(column: string) { + this.call.orders.push(column); + return this; + } + + range(from: number, to: number) { + this.call.range = { from, to }; + return this; + } + + abortSignal(signal: AbortSignal) { + this.call.abortSignals.push(signal); + return this; + } + + then( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return Promise.resolve(this.resolver(this.call)).then(onfulfilled, onrejected); + } +} + +function supabaseMock(resolver: QueryResolver) { + const calls: QueryCall[] = []; + return { + calls, + from: vi.fn((table: string) => { + const call: QueryCall = { table, filters: [], inFilters: [], orders: [], abortSignals: [] }; + calls.push(call); + return new QueryBuilder(call, resolver); + }), + }; +} + describe("search scope filters", () => { it("accepts smart document label filter groups", () => { const filters = searchScopeFiltersSchema.parse({ @@ -55,4 +133,85 @@ describe("search scope filters", () => { summary: "All public documents", }); }); + + it("paginates label rows so later-page label matches are not silently dropped", async () => { + const wantedDocumentId = "22222222-2222-4222-8222-222222222222"; + const supabase = supabaseMock((call) => { + if (call.table === "documents") { + return { + data: [ + { id: "11111111-1111-4111-8111-111111111111", metadata: {}, import_batch_id: null }, + { id: wantedDocumentId, metadata: {}, import_batch_id: null }, + ], + error: null, + }; + } + if (call.table === "document_labels") { + if (call.range?.from === 0) { + return { + data: Array.from({ length: 1000 }, (_, index) => ({ + id: `label-${index.toString().padStart(4, "0")}`, + document_id: "11111111-1111-4111-8111-111111111111", + label: "other topic", + label_type: "topic", + })), + error: null, + }; + } + return { + data: [ + { + id: "label-wanted", + document_id: wantedDocumentId, + label: "clozapine", + label_type: "topic", + }, + ], + error: null, + }; + } + return { data: [], error: null }; + }); + + await expect( + resolveSearchScope({ + supabase: supabase as never, + accessScope: { ownerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", includePublic: false }, + filters: { topics: ["clozapine"] }, + }), + ).resolves.toMatchObject({ + documentIds: [wantedDocumentId], + matchedDocumentCount: 1, + }); + + const labelCalls = supabase.calls.filter((call) => call.table === "document_labels"); + expect(labelCalls.map((call) => call.range)).toEqual([ + { from: 0, to: 999 }, + { from: 1000, to: 1999 }, + ]); + expect(labelCalls.every((call) => call.orders.includes("id"))).toBe(true); + }); + + it("propagates caller cancellation to label scope queries", async () => { + const controller = new AbortController(); + const supabase = supabaseMock((call) => { + if (call.table === "documents") { + return { + data: [{ id: "11111111-1111-4111-8111-111111111111", metadata: {}, import_batch_id: null }], + error: null, + }; + } + return { data: [], error: null }; + }); + + await resolveSearchScope({ + supabase: supabase as never, + accessScope: { ownerId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", includePublic: false }, + filters: { labelTypesAny: ["topic"] }, + signal: controller.signal, + }); + + const labelCall = supabase.calls.find((call) => call.table === "document_labels"); + expect(labelCall?.abortSignals).toContain(controller.signal); + }); }); diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts index d4f3cbd3f..b6f8d3e19 100644 --- a/tests/services-catalog.test.ts +++ b/tests/services-catalog.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { compactBestUseTitle } from "@/lib/compact-best-use-title"; import { catalogToServiceRecord, mapCatalogToServiceRecords } from "@/lib/service-catalog-mapper"; import { loadServicesSnapshot, normalizeCatalogServices } from "@/lib/service-catalog"; import { @@ -31,6 +32,31 @@ describe("services catalogue", () => { expect(record.verification?.confidence).toBe("Medium"); }); + it("compacts pipe-joined best-use blobs on summary cards", () => { + const snapshot = loadServicesSnapshot(); + const crisisCare = snapshot.services.find((service) => service.canonical_name_key === "crisis-care"); + expect(crisisCare?.best_use_indication?.includes("|")).toBe(true); + expect(crisisCare!.best_use_indication.length).toBeGreaterThan(140); + + const record = catalogToServiceRecord(crisisCare!); + const bestUseCard = record.summaryCards?.find((card) => card.id === "best-use"); + expect(bestUseCard?.title).toBe("After-hours crisis, homelessness, FDV, child-safety concerns"); + expect(bestUseCard?.title?.length).toBeLessThanOrEqual(140); + expect(bestUseCard?.title?.includes("|")).toBe(false); + expect(record.criteria?.some((criterion) => criterion.label === crisisCare!.best_use_indication)).toBe(true); + }); + + it("compacts raw best-use fallbacks for stale seeded summary cards", () => { + const snapshot = loadServicesSnapshot(); + const crisisCare = snapshot.services.find((service) => service.canonical_name_key === "crisis-care"); + expect(crisisCare).toBeTruthy(); + + const compacted = compactBestUseTitle(crisisCare!.best_use_indication); + expect(compacted).toBe("After-hours crisis, homelessness, FDV, child-safety concerns"); + expect(compacted.includes("|")).toBe(false); + expect(compacted.length).toBeLessThanOrEqual(140); + }); + it("produces unique slugs and non-empty titles", () => { const records = mapCatalogToServiceRecords(loadServicesSnapshot().services); const slugs = records.map((record) => record.slug); diff --git a/tests/signed-image.dom.test.tsx b/tests/signed-image.dom.test.tsx index 77291283b..154ce195b 100644 --- a/tests/signed-image.dom.test.tsx +++ b/tests/signed-image.dom.test.tsx @@ -83,4 +83,18 @@ describe("SignedImage failure/retry (jsdom)", () => { await user.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByTestId("image-lightbox")).not.toBeInTheDocument()); }); + + it("uses a provided source aspect ratio instead of forcing every document crop into 4:3", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ url: "/demo/wide-table.png" }) }), + ); + + render(); + + const img = await screen.findByRole("img", { name: "Wide table" }); + const frame = img.closest("div"); + expect(frame).toHaveStyle({ aspectRatio: "4.2" }); + expect(frame?.className).not.toContain("aspect-[4/3]"); + }); }); diff --git a/tests/source-authority-tooling.test.ts b/tests/source-authority-tooling.test.ts index 92bfec829..035b00d70 100644 --- a/tests/source-authority-tooling.test.ts +++ b/tests/source-authority-tooling.test.ts @@ -9,6 +9,7 @@ import { isRegistryRecordSource, type SourceAuthorityDocument, } from "@/lib/source-authority-metadata"; +import { classifySourceAuthority } from "@/lib/source-authority-registry"; import { parseBackfillSourceMetadataArgs, runLocalityOnlyBackfill } from "../scripts/backfill-source-metadata"; function document( @@ -256,6 +257,61 @@ describe("source authority metadata tooling", () => { }); }); + it("classifies source designation independently from currency and local validation", () => { + expect( + analyzeSourceLocality( + document({ + file_name: "CAHS-clozapine.pdf", + metadata: { + publisher_code: "CAHS", + publisher: "Child and Adolescent Health Service", + jurisdiction: "Australia/WA", + }, + }), + ).authority, + ).toMatchObject({ designation: "official", officialBasis: "wa_health_service_network" }); + + expect( + classifySourceAuthority({ + publisher_code: "EMHS", + publisher: "East Metropolitan Health Service", + jurisdiction: "Australia/WA", + document_status: "outdated", + clinical_validation_status: "unverified", + extraction_quality: "poor", + }), + ).toMatchObject({ + designation: "official", + officialBasis: "wa_health_service_network", + tier: "supplementary", + }); + expect( + classifySourceAuthority({ + publisher_code: "WAHEALTH", + publisher: "WA Health", + jurisdiction: "Australia/WA", + }), + ).toMatchObject({ designation: "trusted", officialBasis: null }); + expect( + classifySourceAuthority({ + publisher_code: "CAMHS", + publisher: "Child and Adolescent Mental Health Service", + jurisdiction: "Australia/WA", + }), + ).toMatchObject({ designation: "trusted", officialBasis: null }); + expect(classifySourceAuthority({ publisher_code: "BMJ" })).toMatchObject({ designation: "trusted" }); + expect(classifySourceAuthority({ publisher: "BMJ Best Practice" })).toMatchObject({ + designation: "unclassified", + reasonCodes: expect.arrayContaining(["publisher_alias_requires_jurisdiction"]), + }); + expect( + classifySourceAuthority({ source_kind: "registry_record", publisher_code: "EMHS", jurisdiction: "Australia/WA" }), + ).toMatchObject({ + designation: "unclassified", + reasonCodes: expect.arrayContaining(["registry_summary_identity"]), + }); + }); + it("fails closed when a locality metadata patch RPC is rejected", async () => { const rpc = vi.fn().mockResolvedValue({ error: { message: "write denied" } }); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); diff --git a/tests/ui-overlay-css-contract.test.ts b/tests/ui-overlay-css-contract.test.ts index 255968f00..a490f9f81 100644 --- a/tests/ui-overlay-css-contract.test.ts +++ b/tests/ui-overlay-css-contract.test.ts @@ -6,6 +6,9 @@ const read = (relativePath: string) => readFileSync(new URL(`../${relativePath}` const answerResultSurfaceSource = read("src/components/clinical-dashboard/answer-result-surface.tsx"); const sheetSource = read("src/components/ui/sheet.tsx"); const globalStylesSource = read("src/app/globals.css"); +const agentsSource = read("AGENTS.md"); +const searchChromeBehaviourSource = read("docs/search-chrome-behaviour.md"); +const clinicalDashboardSource = read("src/components/ClinicalDashboard.tsx"); function occurrenceCount(source: string, value: string) { return source.split(value).length - 1; @@ -60,4 +63,27 @@ describe("overlay and global CSS contracts", () => { /\.edge-glass-header\s*\{[^}]*padding-left:\s*max\(0px,\s*var\(--safe-area-left\)\)/s, ); }); + + it("keeps hidden phone composers from reserving or painting a bottom white band", () => { + expect(globalStylesSource).toMatch(/--phone-dock-hidden-pad:\s*0rem;/); + expect(globalStylesSource).not.toContain("--phone-dock-hidden-pad: 0.75rem"); + expect(read("src/components/clinical-dashboard/mobile-composer-reserve.ts")).toContain( + 'export const mobileComposerHiddenReserve = "0rem"', + ); + expect(read("src/components/DocumentViewer.tsx")).toContain( + 'composerScrollHidden ? "max-sm:pb-0" : "max-sm:pb-[calc(9rem+var(--safe-area-bottom))]"', + ); + }); + it("keeps the remembered search chrome rules aligned with the hidden reserve contract", () => { + expect(agentsSource).toContain(""); + expect(agentsSource).toContain("Hidden means zero reserve"); + expect(searchChromeBehaviourSource).toContain( + "A hidden phone dock must release the content-facing reserve to `0rem`", + ); + expect(searchChromeBehaviourSource).not.toContain( + "A hidden phone dock must release the content-facing reserve to `0.75rem`", + ); + expect(clinicalDashboardSource).toContain("Hidden dock pad must stay at 0rem"); + expect(clinicalDashboardSource).not.toContain("Hidden dock pad must stay at 0.75rem"); + }); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 04a2db924..a1e688348 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -926,6 +926,29 @@ test.describe("Clinical KB tools launcher", () => { await expectNoPageHorizontalOverflow(page); }); + test("services referral header and best-use guidance stay actionable", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); + + await expect(page.getByRole("heading", { level: 1, name: /referral matches/i })).toBeVisible(); + await expect(page.getByText("Prioritised for crisis support, culturally safe access, and phone referral.")).toBeVisible(); + await expect(page.getByRole("button", { name: "Advanced service filters" })).toBeDisabled(); + await expect(page.getByText("Advanced service filters are coming soon.")).toHaveCount(1); + + const culturallySafe = page.getByRole("button", { name: "Culturally safe" }); + await expect(culturallySafe).toBeVisible(); + await waitForReactEventHandler(culturallySafe); + await culturallySafe.click(); + await expect(page).toHaveURL(/q=Aboriginal\+Torres\+Strait\+Islander/); + await expect(page.getByText("Aboriginal and Torres Strait Islander-specific").first()).toBeVisible(); + + await page.getByTestId("service-search-result-13yarn").getByRole("link", { name: "Open 13YARN" }).click(); + await expect(page).toHaveURL(/\/services\/13yarn$/); + await expect(page.getByText("Best use").first()).toBeVisible(); + await expect(page.getByText(/crisis support/i).first()).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + }); + test("forms mode shows registry-backed form records without unsupported pathway claims", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockAnswerDashboardApi(page); diff --git a/tests/visual-evidence-tabs.dom.test.tsx b/tests/visual-evidence-tabs.dom.test.tsx index 9db75dbec..8bbaaaa84 100644 --- a/tests/visual-evidence-tabs.dom.test.tsx +++ b/tests/visual-evidence-tabs.dom.test.tsx @@ -137,3 +137,243 @@ describe("MobileEvidenceSheetContent tabs (jsdom)", () => { expect(claims).toHaveAttribute("tabindex", "-1"); }); }); +<<<<<<< ours +======= + +describe("ClinicalNotesChecklistPanel visual-evidence boundary (jsdom)", () => { + it("does not expose raw table evidence suppressed by the render model", () => { + const answerWithRawTable: RagAnswer = { + ...answer, + answer: "Monitor renal function and escalate review for vomiting, dehydration, tremor, confusion, or ataxia.", + queryClass: "table_threshold", + responseMode: "threshold_table", + citations: [ + { + chunk_id: "chunk-1", + document_id: "doc-1", + title: "Lithium source", + file_name: "lithium.pdf", + page_number: 1, + chunk_index: 0, + }, + ], + answerSections: [ + { + heading: "Monitoring", + body: "Check lithium level, renal function, thyroid function, calcium, and interacting medicines.", + citation_chunk_ids: ["chunk-1"], + kind: "monitoring_timing", + supportLevel: "direct", + }, + { + heading: "Escalation", + body: "Escalate review for vomiting, dehydration, tremor, confusion, or ataxia.", + citation_chunk_ids: ["chunk-1"], + kind: "escalation_risk", + supportLevel: "direct", + }, + ], + visualEvidence: [ + { + id: "raw-table", + document_id: "doc-1", + image_id: "image-1", + source_chunk_id: "chunk-1", + title: "Raw threshold table", + file_name: "raw-table.pdf", + page_number: 1, + chunk_index: 0, + caption: "Raw table", + tableColumns: ["Threshold", "Action"], + tableRows: [ + ["0.49", "Withhold"], + ["1.0", "Monitor"], + ], + signed_url_endpoint: "/api/images/image-1", + viewer_href: "/documents/doc-1?page=1", + }, + ], + }; + + render( + , + ); + + expect(screen.queryByRole("button", { name: "Tables" })).not.toBeInTheDocument(); + expect(screen.queryByText("0.49")).not.toBeInTheDocument(); + expect(screen.queryByText("Withhold")).not.toBeInTheDocument(); + }); + + it("does not reconstruct clinical-notes sections from untrusted answerSections", () => { + const untrusted: RagAnswer = { + ...answer, + grounded: true, + // Missing relevance.isSourceBacked → fail closed for structured clinical UI. + answerSections: [ + { + heading: "Monitoring", + body: "Check lithium level every 3 months when stable.", + citation_chunk_ids: ["chunk-1"], + kind: "monitoring_timing", + supportLevel: "direct", + }, + { + heading: "Escalation", + body: "Escalate for tremor, confusion, or ataxia.", + citation_chunk_ids: ["chunk-1"], + kind: "escalation_risk", + supportLevel: "direct", + }, + ], + quoteCards: [ + { + chunk_id: "chunk-1", + document_id: "doc-1", + title: "Lithium source", + file_name: "lithium.pdf", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + quote: "Check lithium level every 3 months when stable.", + }, + ], + }; + + render( + , + ); + + expect(screen.queryByText("Check lithium level every 3 months when stable.")).not.toBeInTheDocument(); + expect(screen.queryByText("Escalate for tremor, confusion, or ataxia.")).not.toBeInTheDocument(); + }); + + it("does not reconstruct clinical notes from untrusted labeled answer text", () => { + const untrusted: RagAnswer = { + ...answer, + grounded: true, + // Missing relevance.isSourceBacked → fail closed for parsed clinical UI. + answer: [ + "Action: Withhold lithium and arrange urgent same-day clinical review.", + "Monitoring: Repeat lithium level, renal function, and electrolytes within 24 hours.", + ].join("\n"), + }; + + render( + , + ); + + expect(screen.queryByText("Withhold lithium and arrange urgent same-day clinical review.")).not.toBeInTheDocument(); + expect( + screen.queryByText("Repeat lithium level, renal function, and electrolytes within 24 hours."), + ).not.toBeInTheDocument(); + }); + + it("does not render comparison tables from untrusted documentBreakdown", () => { + const comparisonAnswer: RagAnswer = { + ...answer, + grounded: true, + // Missing relevance.isSourceBacked → fail closed; documentBreakdown/comparison + // metadata must not rebuild ClinicalOutputPanel comparison-detail tables. + responseMode: "comparison_matrix", + queryClass: "comparison", + documentBreakdown: [ + { + document_id: "doc-a", + title: "Guideline A", + file_name: "a.pdf", + top_similarity: 0.9, + source_strength: "strong", + source_count: 1, + quote_count: 1, + pages: [1], + best_quote: "Guideline A prefers weekly lithium monitoring when unstable.", + }, + { + document_id: "doc-b", + title: "Guideline B", + file_name: "b.pdf", + top_similarity: 0.8, + source_strength: "moderate", + source_count: 1, + quote_count: 1, + pages: [2], + best_quote: "Guideline B prefers monthly lithium monitoring when stable.", + }, + ], + comparisonMatrix: { + documents: [ + { documentId: "doc-a", title: "Guideline A", fileName: "a.pdf" }, + { documentId: "doc-b", title: "Guideline B", fileName: "b.pdf" }, + ], + rows: [ + { + parameter: "Monitoring interval", + status: "conflict", + entries: [ + { + documentId: "doc-a", + chunkIds: ["chunk-a"], + value: "weekly when unstable", + qualifiers: [], + }, + { + documentId: "doc-b", + chunkIds: ["chunk-b"], + value: "monthly when stable", + qualifiers: [], + }, + ], + }, + ], + }, + comparisonEvaluationState: "evaluated", + }; + + render( + , + ); + + expect(screen.queryByText("Clinical comparison detail")).not.toBeInTheDocument(); + expect(screen.queryByText("Guideline A prefers weekly lithium monitoring when unstable.")).not.toBeInTheDocument(); + expect(screen.queryByText("Guideline B prefers monthly lithium monitoring when stable.")).not.toBeInTheDocument(); + expect(screen.queryByText("weekly when unstable")).not.toBeInTheDocument(); + expect(screen.queryByText("monthly when stable")).not.toBeInTheDocument(); + }); +}); +>>>>>>> theirs diff --git a/worker/main.ts b/worker/main.ts index 0d0fef3e0..79b053481 100644 --- a/worker/main.ts +++ b/worker/main.ts @@ -941,7 +941,11 @@ async function uploadAndCaptionImages( width: image.width ?? null, height: image.height ?? null, }); - if (lowSignalSkipReason && !["administrative table without clinical facts"].includes(lowSignalSkipReason)) { + if ( + lowSignalSkipReason && + image.sourceKind !== "table_crop" && + !["administrative table without clinical facts"].includes(lowSignalSkipReason) + ) { skippedImages += 1; noteSkippedImage(skipReasons, lowSignalSkipReason); continue; @@ -950,11 +954,49 @@ async function uploadAndCaptionImages( image.sourceKind === "table_crop" ? nonClinicalTableClassification({ tableMetadata, sourceKind: image.sourceKind }) : null; - if (!presetClassification && !selectedCaptionCandidateIndexes.has(index)) { + const retainUncaptionedForDocumentView = + ["table_crop", "diagram_crop", "page_region"].includes(image.sourceKind ?? "") && + !selectedCaptionCandidateIndexes.has(index); + if (!presetClassification && !selectedCaptionCandidateIndexes.has(index) && !retainUncaptionedForDocumentView) { skippedImages += 1; noteSkippedImage(skipReasons, "visual intelligence candidate below caption budget"); continue; } + const fallbackClassification: ImageClassification | null = retainUncaptionedForDocumentView + ? { + image_type: image.sourceKind === "table_crop" ? "clinical_table" : "unclear", + caption: + tableMetadata.tableTitle || + tableMetadata.tableLabel || + (image.sourceKind === "page_region" + ? "Document page region retained for review." + : "Document image retained for review."), + searchable: false, + clinical_relevance_score: 0, + labels: ["needs-review"], + skip_reason: "retained for document view without captioning", + clinical_use_class: "ambiguous", + clinical_use_reason: "Retained for document viewing without model captioning.", + clinical_signal_score: 0, + admin_signal_score: 0, + structured_visual_profile: deterministicStructuredVisualProfile({ + imageType: image.sourceKind === "table_crop" ? "clinical_table" : "unclear", + caption: + tableMetadata.tableTitle || + tableMetadata.tableLabel || + (image.sourceKind === "page_region" + ? "Document page region retained for review." + : "Document image retained for review."), + tableTitle: tableMetadata.tableTitle, + tableLabel: tableMetadata.tableLabel, + tableTextSnippet: tableMetadata.tableTextSnippet, + tableRows: tableMetadata.tableRows as string[][] | null, + tableColumns: tableMetadata.tableColumns as string[] | null, + metadata: {}, + }), + structured_extraction_confidence: 0.25, + } + : null; captionTasks.push({ candidate, index, @@ -964,7 +1006,7 @@ async function uploadAndCaptionImages( nearbyText, tableMetadata, contextHash, - presetClassification, + presetClassification: presetClassification ?? fallbackClassification, }); } @@ -1019,6 +1061,7 @@ async function uploadAndCaptionImages( const { task, classificationCacheHit } = resolved; const { candidate, index, image, perceptualHash, imageHash, nearbyText, tableMetadata, contextHash } = task; let classification = redactImageClassification(resolved.classification); + const retainedWithoutCaptioning = task.presetClassification?.skip_reason === "retained for document view without captioning"; const policyAssessment = assessClinicalImageUse({ imageType: classification.image_type, searchable: classification.searchable, @@ -1068,12 +1111,15 @@ async function uploadAndCaptionImages( image.sourceKind === "table_crop" && ["administrative", "reference"].includes(policyAssessment.clinical_use_class) && classification.image_type !== "logo_decorative"; - if (classifiedSkipReason && !retainAsAuditTable) { + const retainForDocumentView = + retainAsAuditTable || + (["table_crop", "diagram_crop", "page_region"].includes(image.sourceKind ?? "") && retainedWithoutCaptioning); + if (classifiedSkipReason && !retainAsAuditTable && !retainForDocumentView) { skippedImages += 1; noteSkippedImage(skipReasons, classifiedSkipReason); continue; } - const persistedSearchable = policyAssessment.searchable; + const persistedSearchable = !retainedWithoutCaptioning && policyAssessment.searchable; if (persistedSearchable) { imageTypeCounts.set(classification.image_type, (imageTypeCounts.get(classification.image_type) ?? 0) + 1); } @@ -1147,7 +1193,7 @@ async function uploadAndCaptionImages( visual_budget_class: candidate.captionBudgetClass, visual_priority_reasons: candidate.reasons, retained_for_audit: retainAsAuditTable || undefined, - retained_for_document_view: retainAsAuditTable || undefined, + retained_for_document_view: retainForDocumentView || undefined, skip_reason: retainAsAuditTable ? classifiedSkipReason : classification.skip_reason, }), }) @@ -1163,7 +1209,7 @@ async function uploadAndCaptionImages( }); } if (!data) throw new Error("Document image insert returned no row."); - if (data.searchable !== false) { + if (data.searchable !== false || retainForDocumentView) { insertedImages.push({ id: data.id, caption: data.caption, diff --git a/worker/python/extract_pdf_assets.py b/worker/python/extract_pdf_assets.py index f504de76e..eb1d2737f 100644 --- a/worker/python/extract_pdf_assets.py +++ b/worker/python/extract_pdf_assets.py @@ -255,6 +255,7 @@ def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata, bud image_path = os.path.join(output_dir, file_name) dimensions = write_pixmap(pix, image_path, budget) + crop_completeness = crop_completeness_score(rect, clipped, page.rect) crop_metadata = { **metadata, "bbox": rect_payload(rect), @@ -264,6 +265,8 @@ def save_page_crop(page, rect, output_dir, file_name, source_kind, metadata, bud "page_rotation": int(page.rotation or 0), "render_scale": round(render_scale, 3), "render_dpi": round(render_scale * 72), + "crop_completeness": crop_completeness, + "crop_touches_page_edge": crop_completeness < 0.99, "source_regions": [ { "source_kind": source_kind, @@ -293,6 +296,23 @@ def expanded_rect(rect, page_rect, x_padding=4, y_padding=4): ) +def table_expanded_rect(rect, page_rect): + return expanded_rect(rect, page_rect, x_padding=10, y_padding=18) + + +def crop_completeness_score(requested_rect, clipped_rect, page_rect): + requested_area = max(float(requested_rect.width) * float(requested_rect.height), 1.0) + clipped_area = max(float(clipped_rect.width) * float(clipped_rect.height), 0.0) + score = min(1.0, clipped_area / requested_area) + edge_penalty = 0.0 + tolerance = 1.0 + if abs(clipped_rect.x0 - page_rect.x0) <= tolerance or abs(clipped_rect.x1 - page_rect.x1) <= tolerance: + edge_penalty += 0.04 + if abs(clipped_rect.y0 - page_rect.y0) <= tolerance or abs(clipped_rect.y1 - page_rect.y1) <= tolerance: + edge_penalty += 0.04 + return round(max(0.0, score - edge_penalty), 3) + + def rect_intersection_ratio(a, b): intersection = a & b if intersection.is_empty: @@ -937,7 +957,7 @@ def extract(pdf_path, output_dir, budget=None): table_candidates.extend(fallback_table_candidates(page, [candidate["rect"] for candidate in table_candidates])) table_candidates = merge_related_table_candidates(table_candidates, page.rect) for table_candidate in table_candidates: - table_rect = expanded_rect(table_candidate["rect"], page.rect, 4, 4) + table_rect = table_expanded_rect(table_candidate["rect"], page.rect) crop = save_page_crop( page, table_rect, diff --git a/worker/python/test_extract_pdf_assets_budget.py b/worker/python/test_extract_pdf_assets_budget.py index b51b5826f..139e2af8a 100644 --- a/worker/python/test_extract_pdf_assets_budget.py +++ b/worker/python/test_extract_pdf_assets_budget.py @@ -87,6 +87,15 @@ def get_pixmap(self, **_kwargs): self.assertIsNone(warning) self.assertEqual(calls, [{"timeout": 60}]) + def test_crop_completeness_penalizes_page_edge_clipping(self): + page = type("PageRect", (), {"x0": 0, "y0": 0, "x1": 200, "y1": 200, "width": 200, "height": 200})() + requested = type("Rect", (), {"width": 120, "height": 120})() + complete = type("Clip", (), {"width": 120, "height": 120, "x0": 20, "y0": 20, "x1": 140, "y1": 140})() + clipped = type("Clip", (), {"width": 90, "height": 120, "x0": 0, "y0": 20, "x1": 90, "y1": 140})() + + self.assertEqual(extractor.crop_completeness_score(requested, complete, page), 1.0) + self.assertLess(extractor.crop_completeness_score(requested, clipped, page), 0.82) + if __name__ == "__main__": unittest.main()