diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 74e9fca0a..d47b52b47 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -961,9 +961,17 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | execute-audit-code-remediation (PR #1162) | b9b56c140eb14cbba5a2c2230e3fa28d3a791add | CI unblock after bot sync | Tip 96188eca had PR required SUCCESS (Static/Safety/Unit/Build/Migration/Production UI). Hosted pr-branch-sync then merged main (a420b86b/b9b56c14), leaving CI action_required for bot-authored runs. Pushing agent commit to re-trigger non-bot CI. | Prior tip 96188eca hosted CI green; local services referral Playwright PASS; no provider-backed checks. | | 2026-07-25 | PR supersede #1186 / `cursor/pr1186-audit-remediation-c94c` | `a38e83860510a4229d5658960657cd7448aff278` | Clean main-based port of intentional #1186 audit fixes | SUPERSEDE #1186 (do not merge old PR). Ported intentional 16-file delta onto current main; dropped conflicted checkpoint tree and placeholder skills. Fixed eval single results binding; async run-heavy so lock heartbeat fires; branch:cleanup dry-run default + argv-safe deletes; skill-create interface YAML. Close #1186. | Focused Vitest tooling+lock 6/6; check:skills 33; prettier on touched files; no provider/live eval runs. | | 2026-07-25 | `cursor/ledger-066-067-519b` | f04392a408eceee14215c15169cbd6b70ac2041c | Close stale ledger #066/#067 (+ drop resolved #030/#075 from queue) | READY. #066 proven on main via #1174; #067 already fixed in #1191 in-process preflight. Docs-only ledger sync; no code change. | Local proof: `git show`/`log` for #1174/#1191; preflight test already in-process on main; ledger integrity asserts. No providers. | -| 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Cursor review+Bugbot+prlanded+debug (fresh pass, same HEAD) | DO NOT MERGE; NOT LANDED (OPEN, mergeable=CONFLICTING/DIRTY, 468 behind / 2 ahead). Reconfirmed P0: conflict resolution deleted `trustGatedAnswerForClinicalNotes` (0 hits on head; main L584/632/659/1075) — Clinical Notes consumes ungated answer. P1: `src/app/api/answer/route.ts:6` imports nonexistent `@/lib/rag` (tsc TS2307; stream correctly uses `@/lib/rag/rag`). P1: merge-tree conflicts on answer/upload/evidence-panels + 10 paths; literal `<<<<<<<` in docs audit plan; migration timestamp collision risk vs main. Intentional dark-mode delta is only commit `363672602` (~10 files). Salvage: `cursor/pr1190-dark-mode-salvage-f453` cherry-picks that commit onto current main, restores unused-manifest-import cleanup, keeps clinical gate. Close #1190 after salvage lands. | Bugbot; `git grep` gate/import/markers; merge-tree; `tsc` TS2307 proof; gh pr view/checks (PR policy fail). Salvage: tsc clean; visual-evidence+overlay tests 13/13; eslint on changed files. No provider/UI matrix. | + +| 2026-07-25 | PR #1188 / `execute-audit-remediation-plan` | `8b8639113925601e1687bfe4f1f29c44a4308b61` | Explicit Bugbot + protocol review (+ /prlanded + /debug) | DO NOT MERGE tip. Not landed (state OPEN, mergeable CONFLICTING, 468 behind main). P0: ClinicalDashboard orphaned import body (parse break). P0: dangling `renderSystemNotice` after helper extraction. P0: `indexing-v3-agent/utils.ts` `async export function sha256Hex`. P0/P1: `CLINICAL_PHRASE_PATTERN` left in index.ts but used from utils. P0: ~12 files still contain conflict markers from archive base `faa50e6`. P1: PR policy missing Clinical Governance Preflight. P2: notice visibility dropped `answer` gate + `hidden sm:block`. IMP-04 prune unsafe vs current main (still-exported symbols in use). Clean rebuild of intentional remediation on main: `cursor/pr1188-fix-build-breakers-6ee0`. | Bugbot subagent; `git show`/marker scan; esbuild parse of tip utils; `gh pr view/checks`; typecheck + check:github-actions on fix branch. No provider calls. | + +| 2026-07-25 | `cursor/pr1188-fix-build-breakers-6ee0` (PR #1213) | `183291a1f892489218b3d6b6c8a04966cdadd1e3` | Clean rebuild of intentional #1188 remediation on main | READY for review. Ports notice extraction + admin lazy-load + indexing-v3 utils extraction without the dirty archive base / conflict markers / syntax breakers from #1188 tip `8b86391`. Preserves system-notice answer gate + responsive visibility. Recommend close #1188 after this merges. | typecheck; check:github-actions; check:branch-review-ledger; prettier; esbuild utils parse. No provider calls. | +| 2026-07-25 | PR #1188 / `execute-audit-remediation-plan` + replacement PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | tip `8b8639113925601e1687bfe4f1f29c44a4308b61` (broken); rebuild `fd67d6bf3` | Cursor Bugbot+/review+/prlanded+/debug (fresh pass) | DO NOT MERGE #1188; NOT LANDED (OPEN, CONFLICTING/DIRTY, 469 behind). Runtime-proof P0s: tsc TS1185 conflict markers in answer/upload/answer-status/etc; ClinicalDashboard orphaned import body; dangling `renderSystemNotice`; `async export function sha256Hex`. PR policy FAIL (missing Clinical Governance Preflight). Intentional ~11-file delta rebuild is PR #1213 on clean main (tsc/eslint/pins green). Close #1188 after #1213. | Bugbot; isolated worktree tsc on tip; gh pr view/checks; fix-branch tsc+eslint+pins+16 Vitest. No provider/UI matrix. | | 2026-07-25 | execute-audit-remediation-plan (PR #1188) | 8b8639113925601e1687bfe4f1f29c44a4308b61 | Bugbot/diff-review: maintainability remediation tip | BLOCK: tip tree carries unresolved conflict markers (answer/upload APIs, clinical-dashboard, services, tests); invalid `async export function sha256Hex` in indexing-v3 utils; ClinicalDashboard still calls removed `renderSystemNotice`; merge-tree vs main conflicts in check-github-action-pins.mjs + ui-primitives.tsx. Intended notices extraction/dynamic imports look mostly sound; search-scope/migration not in three-dot product delta. | `git grep` conflict markers on tip (none on origin/main); `git show` for ClinicalDashboard:3824 + utils.ts:191; `git merge-tree --write-tree origin/main 8b8639113`; no provider-backed checks. | | 2026-07-25 | PR #1209 / `cursor/pr1186-audit-remediation-c94c` | `4cb22e45dfe46b1975fa15ddd028c7e14ceb5fab` | Close #1186 + babysit #1209 CI | DONE. Closed #1186 as superseded. Synced origin/main (MERGEABLE/CLEAN). Fixed PR-policy RAG impact line. Hosted PR required SUCCESS (Static/Safety/Unit/Build/Production UI/Advisory UI/containers) on pre-ledger tip; docs-only follow-up pushed. Ready to merge; auto-merge not enabled. | gh pr checks; local policy ok; no provider/eval runs. | + +| 2026-07-25 | PR #1188 / `execute-audit-remediation-plan` | `8b8639113925601e1687bfe4f1f29c44a4308b61` | prlanded + close as superseded | CLOSED (not merged). Content never landed; tip remained CONFLICTING with P0 build breakers. Superseded by PR #1213 (`cursor/pr1188-fix-build-breakers-6ee0`). | Final P0 scan on #1213 tip clean; focused Vitest 16/16; node --check utils; check:github-actions. Closed via ManagePullRequest with supersession comment. | +| 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | `64b13fba5d8c7c97dac553d02a8dd4c2b5522e1e` | Safe land handoff after #1188 close | #1188 CLOSED superseded. Tip was bot-merge-only so hosted CI sat in action_required; pushing agent commit to re-trigger non-bot CI before squash-merge to main. merge-tree clean vs main; intentional rebuild (notices/lazy/utils) intact. | gh run list action_required on bot tip; merge-tree clean; no provider calls. | +| 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Cursor review+Bugbot+prlanded+debug (fresh pass, same HEAD) | DO NOT MERGE; NOT LANDED (OPEN, mergeable=CONFLICTING/DIRTY, 468 behind / 2 ahead). Reconfirmed P0: conflict resolution deleted `trustGatedAnswerForClinicalNotes` (0 hits on head; main L584/632/659/1075) — Clinical Notes consumes ungated answer. P1: `src/app/api/answer/route.ts:6` imports nonexistent `@/lib/rag` (tsc TS2307; stream correctly uses `@/lib/rag/rag`). P1: merge-tree conflicts on answer/upload/evidence-panels + 10 paths; literal `<<<<<<<` in docs audit plan; migration timestamp collision risk vs main. Intentional dark-mode delta is only commit `363672602` (~10 files). Salvage: `cursor/pr1190-dark-mode-salvage-f453` cherry-picks that commit onto current main, restores unused-manifest-import cleanup, keeps clinical gate. Close #1190 after salvage lands. | Bugbot; `git grep` gate/import/markers; merge-tree; `tsc` TS2307 proof; gh pr view/checks (PR policy fail). Salvage: tsc clean; visual-evidence+overlay tests 13/13; eslint on changed files. No provider/UI matrix. | | 2026-07-25 | cursor/pr1197-fix-regressions-d06a (PR #1197 fix) | c1e9696de6000440ad5b44e4d2fb2738a858deae | Fix-forward after Bugbot do-not-merge review | FIXED for tip. Synced to origin/main; restored clinical-notes trust gate + boundary tests + summaryMode 400 contract by taking main; removed ISSUE-07 RAG pre-classifier (rag.ts matches main � RAG impact: no retrieval behaviour change). Kept only additive SQL remediation: migration 20260725000000 (real worker URL + ISSUE-05 revokes), schema.sql URL/[REDACTED] fix + ISSUE-05, regenerated drift-manifest. Dropped broken upload authority refs (undefined canonicalAuthority). | Vitest: summaryMode reject + ClinicalNotes boundary 4/4; rag-classifier-memo + rag-tail-latency 17/17; upload smart-title/cleanup 3/3; check:migration-role pass; drift:manifest regenerated via Docker. No provider-backed checks. | | 2026-07-25 | execute-system-audit-remediation (PR #1197) | c7ae011683614c8de6027347b49e8b3fb79dfa34 | Merge-ready polish: grant reassert + CI green path | READY. Hardened migration/schema to reassert service_role EXECUTE after CREATE OR REPLACE / ISSUE-05 revokes; drift regenerated. Prior regressions remain fixed (trust gate, summaryMode, no RAG pre-classifier). Delta vs main: migration + schema + drift + ledger only. | check:migration-role; drift:manifest; supabase-schema + function-grants Vitest 83/83; awaiting hosted PR required on tip. | | 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | pending-ci-retrigger | CI unblock after bot sync | Hosted pr-branch-sync merged main onto tip (`68d0ceaab`), leaving CI `action_required` for bot-authored runs. Pushing agent commit to re-trigger non-bot CI before squash-merge; then close #1190. | Local pwa-manifest 8/8; trust gate present; prior unit failure fixed. No provider-backed checks. | @@ -971,3 +979,4 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | pending-ci-retrigger-2 | CI unblock + skip-branch-sync | Repeated pr-branch-sync bot merges left CI `action_required`. Applied `skip-branch-sync` label and agent retrigger so required checks can finish for squash-merge; then close #1190. | Hosted CI pending on tip; no provider-backed checks. | | 2026-07-25 | cursor/pr1190-dark-mode-salvage-f453 (PR #1214) | `e19442240afbd7f28c321e399c6b4dcb0a7c9fdf` / squash `bb6b394617cbd906285ebe19e0e452912793320a` | prlanded after squash merge | MERGED. Two-dot content diff vs `origin/main` empty; clinical gate preserved; `no-hardcoded-hex` + theme CSS on main; PWA manifest theme colours intentionally absent. Hosted PR required SUCCESS (Unit/Build/Static/Production UI). Remote salvage branch deleted by squash `--delete-branch`. | `gh pr view` MERGED; `git diff origin/main e19442240` empty; trustGated grep on main; no provider-backed checks. | | 2026-07-25 | PR #1190 / `remediate-dark-mode-audit` | `00eca49b9b0d7e5fbfa5703a15e9e930963984a6` | Close without merge after #1214 salvage | CLOSED (not merged). Unsafe tip superseded by #1214. Remote branch `remediate-dark-mode-audit` retained pending optional cleanup; do not merge. | `gh pr view` state=CLOSED mergedAt=null; no provider-backed checks. | +| 2026-07-25 | PR #1213 / `cursor/pr1188-fix-build-breakers-6ee0` | `c45189c76502050002e579cf2038157416a65f2d` | Deduplicate ledger rows before land | Removed 2 exact duplicate table row(s) introduced by union-merge churn (no unique review content lost). Production UI green after Sheet autofocus upgrade. | check:branch-review-ledger; hosted Production UI success on tip. | diff --git a/scripts/check-github-action-pins.mjs b/scripts/check-github-action-pins.mjs index 55b98a8bf..16ba0ebb8 100644 --- a/scripts/check-github-action-pins.mjs +++ b/scripts/check-github-action-pins.mjs @@ -168,6 +168,11 @@ if (!/^ image: semgrep\/semgrep@sha256:[0-9a-f]{64}\s*$/m.test(semgrepGateJ failures.push("sast.yml: the blocking ingestion gate container must be digest-pinned (semgrep/semgrep@sha256:...)."); } +// One SHA per action across every workflow AND composite action. Dependabot bumps +// one file at a time, so a laggard can sit on an old major indefinitely; because +// 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. const actionPinPattern = /uses:\s*([^@\s]+)@([0-9a-f]{40})(?:\s*#\s*(\S+))?/; const shasByAction = new Map(); for (const filePath of discoverGitHubActionFiles(process.cwd())) { diff --git a/scripts/decompose-indexing-v3.mjs b/scripts/decompose-indexing-v3.mjs new file mode 100644 index 000000000..229414dcf --- /dev/null +++ b/scripts/decompose-indexing-v3.mjs @@ -0,0 +1,86 @@ +import fs from "fs"; + +const indexFile = "supabase/functions/indexing-v3-agent/index.ts"; +const utilsFile = "supabase/functions/indexing-v3-agent/utils.ts"; + +let content = fs.readFileSync(indexFile, "utf-8"); + +const utilsToExtract = [ + "normalizeText", + "tokenize", + "safeRecord", + "compactString", + "uniqueStrings", + "structuredProfileFromMetadata", + "stringArrayFrom", + "textItemsFrom", + "sourceRegionsFromMetadata", + "isLowQualityLabel", + "phraseLabelCandidates", + "sha256Hex", + "sleep", + "normalizeLabel", + "normalizeLabelCandidate", + "canonicalUnitType", + "canonicalFieldType", +]; + +let utilsContent = `// Extracted utilities for indexing-v3-agent\n\n`; + +const regexesToExtract = ["LABEL_STOPWORDS", "GENERIC_LABELS", "CLINICAL_PHRASE_PATTERN"]; + +let modifiedContent = content; + +for (const constName of regexesToExtract) { + // Match Set(...) consts and multiline regex literals. Avoid `[\s\S]*?;` for + // regexes: character classes like `[\s:/-]` can confuse naive scanners, and a + // `/.../gi;` body must be taken as a whole. + const patterns = [ + new RegExp(`const ${constName} = new Set\\([\\s\\S]*?\\]\\);\\n+`), + new RegExp(`const ${constName} =\\s*\\n\\s*/[\\s\\S]*?/gi;\\n+`), + new RegExp(`const ${constName} = [\\s\\S]*?;\\n+`), + ]; + let matched = false; + for (const r of patterns) { + const match = modifiedContent.match(r); + if (!match) continue; + utilsContent += `export ` + match[0] + "\n"; + modifiedContent = modifiedContent.replace(r, ""); + matched = true; + break; + } + if (!matched) { + console.warn(`decompose-indexing-v3: did not extract ${constName}`); + } +} + +for (const fn of utilsToExtract) { + const r = new RegExp(`(async )?function ${fn}\\([\\s\\S]*?\\n}\\n+`, "g"); + const match = modifiedContent.match(r); + if (match) { + // Prefer the async form first. Replacing bare `function` before `async function` + // yields invalid `async export function` syntax. + let replaced = match[0]; + if (replaced.startsWith(`async function ${fn}(`)) { + replaced = replaced.replace(`async function ${fn}(`, `export async function ${fn}(`); + } else { + replaced = replaced.replace(`function ${fn}(`, `export function ${fn}(`); + } + utilsContent += replaced + "\n"; + modifiedContent = modifiedContent.replace(r, ""); + } +} + +const imports = `import { + ${utilsToExtract.join(",\n ")} +} from "./utils.ts";\n`; + +modifiedContent = modifiedContent.replace( + 'import postgres from "npm:postgres@3.4.7";', + `import postgres from "npm:postgres@3.4.7";\n` + imports, +); + +fs.writeFileSync(utilsFile, utilsContent); +fs.writeFileSync(indexFile, modifiedContent); + +console.log("Decomposed into utils.ts"); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index e174204f9..4a4bf10b9 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -18,7 +18,6 @@ import { ShieldAlert, Square, UploadCloud, - WifiOff, Wrench, } from "lucide-react"; import { @@ -61,10 +60,6 @@ import { ClinicalMobileSidebar, } from "@/components/clinical-dashboard/ClinicalSidebar"; import { - SetupChecklist, - UploadPanel, - IndexingMonitor, - IngestionQualityConsole, LibraryHealthStrip, fallbackSetupChecks, hasReadyRequiredPublicSearchConfig, @@ -73,6 +68,7 @@ import { type IngestionQualityReviewItem, } from "@/components/clinical-dashboard/DocumentManagerPanel"; import { GuideDialog, GuideTrigger, UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; +import { SystemNotice, DegradedNotice } from "@/components/clinical-dashboard/dashboard-notices"; import { sanitizeAnswerDisplayText, sanitizeDisplayText } from "@/components/clinical-dashboard/display-text"; import { isPreformattedGroundedAnswer, ScopeAndGovernanceNotice } from "@/components/clinical-dashboard/answer-content"; import { AnswerEmptyState, AnswerProgressStepper, AnswerSkeleton } from "@/components/clinical-dashboard/answer-status"; @@ -120,10 +116,14 @@ import { DocumentDrawer, DocumentSearchResultsPanel, FavouritesHub, + IndexingMonitor, + IngestionQualityConsole, loadStagedAnswerResultSurface, MedicationPrescribingWorkspace, RelatedDocumentsPanel, + SetupChecklist, StagedAnswerResultSurface, + UploadPanel, } from "@/components/clinical-dashboard/clinical-dashboard-lazy"; import { clearLegacyRecentQueries, demoRecentQueryOwnerId, recentQueryStorageKey } from "@/lib/recent-query-storage"; @@ -3012,23 +3012,6 @@ export function ClinicalDashboard({ empty: !answer || (answerRenderModel?.reviewSources.length ?? 0) === 0, }, ] as const; - const renderSystemNotice = (className?: string) => ( - -

- {demoMode - ? "Demo mode is active with three synthetic indexed documents, citations, source cards, image captions, and document links. Synthetic data only; not clinical guidance." - : `Configure .env.local and run supabase/schema.sql before uploading or searching. ${setupWarning}`} -

-
- ); const showAuthPanel = false; const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch); const submittedAnswerSearchActive = @@ -3093,28 +3076,6 @@ export function ClinicalDashboard({ heroOwnsPhoneComposer, }), ); - const renderDegradedNotice = () => ( - -

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

-
- ); const setupReadyCount = setupChecks.filter((check) => check.status === "ready").length; const setupCheckCount = setupChecks.length || fallbackSetupChecks.length; const activeUploadWork = @@ -3505,8 +3466,10 @@ export function ClinicalDashboard({ {actionNotice.message} )} - {showDegradedNotice && renderDegradedNotice()} - {showSystemNotice && answer ? renderSystemNotice("hidden sm:block") : null} + {showDegradedNotice && } + {showSystemNotice && answer ? ( + + ) : null}
- {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} + {showSystemNotice && answer ? ( + + ) : null} {activeModeResultKind === "answer" && answer && ( import("@/components/clinical-dashboard/document-search-results").then((m) => m.DocumentSearchResultsPanel), { ssr: false }, ); + +// Admin/setup tools are rare paths — keep them out of the initial dashboard chunk. +export const SetupChecklist = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.SetupChecklist), + { ssr: false }, +); +export const UploadPanel = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.UploadPanel), + { ssr: false }, +); +export const IndexingMonitor = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.IndexingMonitor), + { ssr: false }, +); +export const IngestionQualityConsole = dynamic( + () => import("@/components/clinical-dashboard/DocumentManagerPanel").then((m) => m.IngestionQualityConsole), + { ssr: false }, +); diff --git a/src/components/clinical-dashboard/dashboard-notices.tsx b/src/components/clinical-dashboard/dashboard-notices.tsx new file mode 100644 index 000000000..d950fd1b1 --- /dev/null +++ b/src/components/clinical-dashboard/dashboard-notices.tsx @@ -0,0 +1,56 @@ +import { CircleAlert, WifiOff } from "lucide-react"; +import { UtilityDrawer } from "@/components/clinical-dashboard/dashboard-shell"; +import { isDeployedClinicalKb } from "@/lib/deployed-app"; + +export function SystemNotice({ + demoMode, + setupWarning, + className, +}: { + demoMode: boolean; + setupWarning: string | null; + className?: string; +}) { + return ( + +

+ {demoMode + ? "Demo mode is active with three synthetic indexed documents, citations, source cards, image captions, and document links. Synthetic data only; not clinical guidance." + : `Configure .env.local and run supabase/schema.sql before uploading or searching. ${setupWarning}`} +

+
+ ); +} + +export function DegradedNotice({ isOnline }: { isOnline: boolean }) { + return ( + +

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

+
+ ); +} diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index aa8c4c6c1..596ac9b9f 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -204,22 +204,28 @@ export function Sheet({ } pushSheet(sheetId); const focusFrame = window.requestAnimationFrame(() => { - const resolveFocusTarget = () => + const resolveAutofocusTarget = () => initialFocusRef?.current ?? panelRef.current?.querySelector('[data-sheet-autofocus="true"]') ?? - closeRef.current; + null; const focusIfNeeded = () => { // Never reclaim focus once a newer Sheet is stacked above this one. if (!isTopmostSheet(sheetId)) return null; - const focusTarget = resolveFocusTarget(); + const preferredAutofocus = resolveAutofocusTarget(); + const focusTarget = preferredAutofocus ?? closeRef.current; if (!focusTarget?.isConnected) return null; if (document.activeElement === focusTarget) return focusTarget; // Reclaim when focus is outside the panel (or still on body after a - // remount). Do not steal from another in-panel control the user tabbed to. + // remount). Also upgrade from the close-button fallback once a deferred + // `data-sheet-autofocus` child mounts (lazy DocumentDrawer / UtilityDrawer). + // Do not steal from another in-panel control the user tabbed to. const active = document.activeElement; + const activeIsCloseFallback = + preferredAutofocus != null && closeRef.current != null && active === closeRef.current; if ( active == null || active === document.body || + activeIsCloseFallback || (panelRef.current != null && !panelRef.current.contains(active)) ) { focusTarget.focus({ preventScroll: true }); @@ -229,16 +235,16 @@ export function Sheet({ focusIfNeeded(); // Retries cover sibling-sheet teardown and late-mounted autofocus inputs // (UtilityDrawer media sync / deferred drawer children). Keep retrying long - // enough to outlast focus=1 hydration (rAF + ~300ms) and composer reclaim. + // enough to outlast focus=1 hydration (rAF + ~300ms), composer reclaim, and + // a slow dynamic import of DocumentDrawer before the Find field mounts. let attempts = 0; const retryTimer = window.setInterval(() => { attempts += 1; - const focusTarget = focusIfNeeded(); - if ( - attempts >= 40 || - !isTopmostSheet(sheetId) || - (focusTarget != null && document.activeElement === focusTarget) - ) { + // Keep defending preferred autofocus for the full window. An early stop + // after the first successful focus lets focus=1 / composer reclaim steal + // the Find field and leave UI smoke stuck on a non-focused dialog input. + focusIfNeeded(); + if (attempts >= 200 || !isTopmostSheet(sheetId)) { window.clearInterval(retryTimer); if (restoreTimers.openFocusRetry === retryTimer) { restoreTimers.openFocusRetry = null; diff --git a/supabase/functions/indexing-v3-agent/index.ts b/supabase/functions/indexing-v3-agent/index.ts index 0fb407e86..f1c721ec0 100644 --- a/supabase/functions/indexing-v3-agent/index.ts +++ b/supabase/functions/indexing-v3-agent/index.ts @@ -1,5 +1,23 @@ import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import postgres from "npm:postgres@3.4.7"; +import { + normalizeText, + tokenize, + safeRecord, + compactString, + uniqueStrings, + structuredProfileFromMetadata, + stringArrayFrom, + textItemsFrom, + sourceRegionsFromMetadata, + phraseLabelCandidates, + sha256Hex, + sleep, + normalizeLabelCandidate, + canonicalUnitType, + canonicalFieldType, +} from "./utils.ts"; + import { completionGateFromRow, deferralDecision, @@ -214,10 +232,6 @@ async function authorizeRequest(req: Request): Promise { return null; } -function normalizeText(v: string): string { - return v.replace(/\s+/g, " ").trim(); -} - function assertEmbeddingDim(vec: unknown, context: string): asserts vec is number[] { if (!Array.isArray(vec)) { throw new Error(`${context} embedding must be an array`); @@ -231,191 +245,6 @@ function assertEmbeddingDim(vec: unknown, context: string): asserts vec is numbe } } -function tokenize(v: string): string[] { - return Array.from( - new Set( - normalizeText(v) - .toLowerCase() - .split(/[^a-z0-9]+/g) - .filter((x) => x.length > 2), - ), - ).slice(0, 40); -} - -function safeRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; -} - -function compactString(value: unknown, limit = 180): string { - const text = normalizeText(String(value ?? "")); - return text.length > limit ? text.slice(0, limit).trim() : text; -} - -function uniqueStrings(values: string[], limit = 20): string[] { - const seen = new Set(); - const out: string[] = []; - for (const value of values) { - const normalized = normalizeText(value); - const key = normalized.toLowerCase(); - if (!normalized || seen.has(key)) continue; - seen.add(key); - out.push(normalized); - if (out.length >= limit) break; - } - return out; -} - -function structuredProfileFromMetadata(metadata: Record): Record { - return safeRecord(metadata.structured_visual_profile ?? metadata.v3_structured_visual); -} - -function stringArrayFrom(value: unknown, limit = 20): string[] { - if (!Array.isArray(value)) return []; - return uniqueStrings(value.map((entry) => compactString(entry, 180)).filter(Boolean), limit); -} - -function textItemsFrom(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.flatMap((entry) => { - if (typeof entry === "string") return [entry]; - const row = safeRecord(entry); - return [row.label, row.name, row.parameter, row.value, row.threshold, row.action, row.management, row.source_text] - .map((part) => compactString(part, 180)) - .filter(Boolean); - }); -} - -function sourceRegionsFromMetadata(metadata: Record): Array> { - const profile = structuredProfileFromMetadata(metadata); - const regions = Array.isArray(profile.source_regions) ? profile.source_regions.map(safeRecord) : []; - const metadataRegions = Array.isArray(metadata.source_regions) ? metadata.source_regions.map(safeRecord) : []; - const directRegion = safeRecord(metadata.source_region); - const bbox = Array.isArray(metadata.bbox) ? { bbox: metadata.bbox } : {}; - return [ - ...regions, - ...metadataRegions, - ...(Object.keys(directRegion).length ? [directRegion] : []), - ...(Object.keys(bbox).length ? [bbox] : []), - ].slice(0, 12); -} - -const LABEL_STOPWORDS = new Set([ - "about", - "above", - "after", - "again", - "against", - "also", - "and", - "are", - "because", - "been", - "before", - "being", - "between", - "both", - "can", - "for", - "from", - "has", - "have", - "how", - "into", - "not", - "off", - "onto", - "other", - "our", - "out", - "over", - "should", - "than", - "that", - "the", - "their", - "then", - "there", - "these", - "this", - "those", - "under", - "was", - "were", - "when", - "where", - "which", - "while", - "with", - "within", - "without", -]); - -const GENERIC_LABELS = new Set([ - "document", - "documents", - "information", - "guidance", - "content", - "summary", - "section", - "sections", - "page", - "table", - "figure", - "clinical", - "patient", - "patients", - "management", - "treatment", -]); - -const CLINICAL_PHRASE_PATTERN = - /\b(?:clozapine|lithium|olanzapine|haloperidol|benzodiazepine|lorazepam|diazepam|antipsychotic|antidepressant|insulin|heparin|warfarin|digoxin|dose|route|threshold|monitoring|observation|escalation|self harm|suicide|violence|agitation|risk matrix|flowchart|care plan|discharge|admission|assessment|screening|contraindication|side effect|adverse effect|fbc|anc|wbc|mmol|mg)\b(?:[\s:/-]+[a-z0-9]{3,}){0,3}/gi; - -function isLowQualityLabel(normalized: string): boolean { - const tokens = normalized.split(/\s+/).filter(Boolean); - if (tokens.length === 0 || tokens.length > 8) return true; - if (!/[a-z]/.test(normalized)) return true; - if (tokens.every((token) => LABEL_STOPWORDS.has(token))) return true; - if (tokens.length === 1 && (LABEL_STOPWORDS.has(tokens[0]) || GENERIC_LABELS.has(tokens[0]))) return true; - if (tokens.filter((token) => !LABEL_STOPWORDS.has(token)).length === 0) return true; - return false; -} - -function phraseLabelCandidates(text: string, limit = 6): string[] { - const phrases = Array.from(text.matchAll(CLINICAL_PHRASE_PATTERN)).map((match) => match[0]); - const tokens = normalizeText(text) - .toLowerCase() - .split(/[^a-z0-9]+/g) - .filter((token) => token.length > 2 && !LABEL_STOPWORDS.has(token)); - - for (let index = 0; index < tokens.length && phrases.length < limit * 2; index += 1) { - const token = tokens[index]; - if (GENERIC_LABELS.has(token) && !/(risk|dose|monitor|threshold|flowchart|clozapine|lithium|agitation)/.test(token)) - continue; - const next = tokens[index + 1]; - const third = tokens[index + 2]; - if (next) phrases.push([token, next, third].filter(Boolean).join(" ")); - } - - return uniqueStrings( - phrases.map((phrase) => normalizeLabel(phrase)).filter((phrase) => !isLowQualityLabel(phrase)), - limit, - ); -} - -async function sha256Hex(input: string): Promise { - const data = new TextEncoder().encode(input); - const digest = await crypto.subtle.digest("SHA-256", data); - return Array.from(new Uint8Array(digest)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - async function fetchEmbeddingBatch(texts: string[]): Promise { let lastError: unknown = null; @@ -878,52 +707,6 @@ function unitsFromStructured(image: ImageRow, structured: ReturnType, rawLabel: string, diff --git a/supabase/functions/indexing-v3-agent/utils.ts b/supabase/functions/indexing-v3-agent/utils.ts new file mode 100644 index 000000000..99ab648b0 --- /dev/null +++ b/supabase/functions/indexing-v3-agent/utils.ts @@ -0,0 +1,244 @@ +// Extracted utilities for indexing-v3-agent + +export const LABEL_STOPWORDS = new Set([ + "about", + "above", + "after", + "again", + "against", + "also", + "and", + "are", + "because", + "been", + "before", + "being", + "between", + "both", + "can", + "for", + "from", + "has", + "have", + "how", + "into", + "not", + "off", + "onto", + "other", + "our", + "out", + "over", + "should", + "than", + "that", + "the", + "their", + "then", + "there", + "these", + "this", + "those", + "under", + "was", + "were", + "when", + "where", + "which", + "while", + "with", + "within", + "without", +]); + +export const GENERIC_LABELS = new Set([ + "document", + "documents", + "information", + "guidance", + "content", + "summary", + "section", + "sections", + "page", + "table", + "figure", + "clinical", + "patient", + "patients", + "management", + "treatment", +]); + +export function normalizeText(v: string): string { + return v.replace(/\s+/g, " ").trim(); +} + +export function tokenize(v: string): string[] { + return Array.from( + new Set( + normalizeText(v) + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter((x) => x.length > 2), + ), + ).slice(0, 40); +} + +export function safeRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +export function compactString(value: unknown, limit = 180): string { + const text = normalizeText(String(value ?? "")); + return text.length > limit ? text.slice(0, limit).trim() : text; +} + +export function uniqueStrings(values: string[], limit = 20): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const normalized = normalizeText(value); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) continue; + seen.add(key); + out.push(normalized); + if (out.length >= limit) break; + } + return out; +} + +export function structuredProfileFromMetadata(metadata: Record): Record { + return safeRecord(metadata.structured_visual_profile ?? metadata.v3_structured_visual); +} + +export function stringArrayFrom(value: unknown, limit = 20): string[] { + if (!Array.isArray(value)) return []; + return uniqueStrings(value.map((entry) => compactString(entry, 180)).filter(Boolean), limit); +} + +export function textItemsFrom(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (typeof entry === "string") return [entry]; + const row = safeRecord(entry); + return [row.label, row.name, row.parameter, row.value, row.threshold, row.action, row.management, row.source_text] + .map((part) => compactString(part, 180)) + .filter(Boolean); + }); +} + +export function sourceRegionsFromMetadata(metadata: Record): Array> { + const profile = structuredProfileFromMetadata(metadata); + const regions = Array.isArray(profile.source_regions) ? profile.source_regions.map(safeRecord) : []; + const metadataRegions = Array.isArray(metadata.source_regions) ? metadata.source_regions.map(safeRecord) : []; + const directRegion = safeRecord(metadata.source_region); + const bbox = Array.isArray(metadata.bbox) ? { bbox: metadata.bbox } : {}; + return [ + ...regions, + ...metadataRegions, + ...(Object.keys(directRegion).length ? [directRegion] : []), + ...(Object.keys(bbox).length ? [bbox] : []), + ].slice(0, 12); +} + +export const CLINICAL_PHRASE_PATTERN = + /\b(?:clozapine|lithium|olanzapine|haloperidol|benzodiazepine|lorazepam|diazepam|antipsychotic|antidepressant|insulin|heparin|warfarin|digoxin|dose|route|threshold|monitoring|observation|escalation|self harm|suicide|violence|agitation|risk matrix|flowchart|care plan|discharge|admission|assessment|screening|contraindication|side effect|adverse effect|fbc|anc|wbc|mmol|mg)\b(?:[\s:/-]+[a-z0-9]{3,}){0,3}/gi; + +export function isLowQualityLabel(normalized: string): boolean { + const tokens = normalized.split(/\s+/).filter(Boolean); + if (tokens.length === 0 || tokens.length > 8) return true; + if (!/[a-z]/.test(normalized)) return true; + if (tokens.every((token) => LABEL_STOPWORDS.has(token))) return true; + if (tokens.length === 1 && (LABEL_STOPWORDS.has(tokens[0]) || GENERIC_LABELS.has(tokens[0]))) return true; + if (tokens.filter((token) => !LABEL_STOPWORDS.has(token)).length === 0) return true; + return false; +} + +export function phraseLabelCandidates(text: string, limit = 6): string[] { + const phrases = Array.from(text.matchAll(CLINICAL_PHRASE_PATTERN)).map((match) => match[0]); + const tokens = normalizeText(text) + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter((token) => token.length > 2 && !LABEL_STOPWORDS.has(token)); + + for (let index = 0; index < tokens.length && phrases.length < limit * 2; index += 1) { + const token = tokens[index]; + if (GENERIC_LABELS.has(token) && !/(risk|dose|monitor|threshold|flowchart|clozapine|lithium|agitation)/.test(token)) + continue; + const next = tokens[index + 1]; + const third = tokens[index + 2]; + if (next) phrases.push([token, next, third].filter(Boolean).join(" ")); + } + + return uniqueStrings( + phrases.map((phrase) => normalizeLabel(phrase)).filter((phrase) => !isLowQualityLabel(phrase)), + limit, + ); +} + +export async function sha256Hex(input: string): Promise { + const data = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest("SHA-256", data); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function normalizeLabel(value: string): string { + const cleaned = normalizeText( + value + .toLowerCase() + .replace(/["'`]|[().,:;!?[\]{}]/g, " ") + .replace(/\s+/g, " "), + ); + return cleaned.slice(0, 72).trim(); +} + +export function normalizeLabelCandidate(rawLabel: string): string | null { + const normalized = normalizeLabel(rawLabel); + if (!normalized || normalized.length < 3) return null; + if (["unknown", "n/a", "na", "tbc", "nil"].includes(normalized)) return null; + if (isLowQualityLabel(normalized)) return null; + return normalized; +} + +export function canonicalUnitType(unitType: string): string { + switch (unitType) { + case "flowchart_step": + case "diagram_decision": + return "workflow_step"; + case "table_threshold": + case "risk_matrix_cell": + return "threshold"; + case "medication_chart_row": + return "medication_monitoring"; + case "visual_askable_question": + return "askable_question"; + case "visual_summary": + case "chart_finding": + default: + return "clinical_fact"; + } +} + +export function canonicalFieldType(unitType: string): string { + switch (unitType) { + case "flowchart_step": + case "diagram_decision": + case "medication_chart_row": + return "clinical_action"; + case "table_threshold": + case "risk_matrix_cell": + return "threshold_fact"; + case "visual_summary": + case "chart_finding": + case "visual_askable_question": + default: + return "image_caption"; + } +} diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 9e554a2cb..8d6468032 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -3147,11 +3147,9 @@ test.describe("Clinical KB UI smoke coverage", () => { await openSourcesButton.click(); const resultsLibraryDialog = page.getByRole("dialog", { name: "Sources" }); await expect(resultsLibraryDialog).toBeVisible(); - await expect - .poll(async () => - resultsLibraryDialog.getByPlaceholder("Find a document").evaluate((el) => el === document.activeElement), - ) - .toBe(true); + // Prefer Playwright's focus waiter over a raw activeElement poll — Sheet + // autofocus can land after lazy DocumentDrawer mount + composer focus=1. + await expect(resultsLibraryDialog.getByPlaceholder("Find a document")).toBeFocused({ timeout: 15_000 }); const sourceDialogBox = await resultsLibraryDialog.boundingBox(); expect(sourceDialogBox).not.toBeNull(); expect(sourceDialogBox?.y ?? -1).toBeGreaterThanOrEqual(0);