diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a34c4f65f7..351f9d4f5f 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -840,7 +840,7 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | claude/document-viewer-phase-3-bj5k5v | 156db63f1b60f09791e426b043ea90d427b789ab | post-#1772 test simplification: replace the viewer perf source-text grep with behavioural coverage; de-literalise rail window and keyboard label assertions | PR #1777 opened. Self-review of #1772's own tests against an excessive-strictness challenge. Finding: the client-performance-boundaries grep for resolveLiveCanvasWindow / resolveRenderAheadPages / liveCanvasLimit / requestIdleCallback was not merely brittle, it was INEFFECTIVE - replacing the budget call with a hardcoded 3 leaves every identifier in the file, so it stayed green while the viewer retained three full-zoom canvases (measured both ways). Replaced by a DOM case that binds the budget (VIEWER_MAX_ZOOM at dpr 3 gives ~16.8M backing px against the 24M budget, window collapses to 1) and fails on exactly that substitution. Also exported RAIL_IMAGE_WINDOW so the rail test derives its counts (verified by tuning 6->8: all 7 still pass), and relaxed the keyboard aria-label assertions from exact prose to the key names. Pre-existing greps for disableAutoFetch / canvas.width = 0 / pageToCleanup left alone deliberately - two are now redundant but they are another author's guard. | verify:pr-local (1 pre-existing root-only failure: pr-handoff-stop #291; 5872 passed), build OK 80s + client bundle secret check, eval:rag:offline 36 golden cases / 574 tests, lint + typecheck clean. Sabotage-verified in both directions. Browser gates unrunnable here (#279) - unchanged by this diff. | | 2026-08-09 | claude/planning-build-intelligence-9ot0nm | 3df3cb3993f73cda4dbbc4ac7549f84b3c6ea7ed | Node 24.15 engine floor: engines.node, preinstall hook, check:runtime, session-start provisioning, codex-cloud assertion | Authored and handed off as PR #1771; closes #285; operationalRisk true, clinicalRisk/ragRanking false | test 5800 passed/1 pre-existing root-uid failure (pr-handoff-stop, confirmed on stashed clean tree); lint 0; typecheck 0; prettier --check . pass; check:runtime pass; check:codex-cloud pass; check:outstanding-issues pass; preinstall boundary proof 24.13/24.14.9 reject, 24.15/24.19 accept, 25.0.0 reject; contract test mutation-checked red | | 2026-08-09 | pull/1771 | 466ec4216272c31c5f754db213dbdc529583b167 | PR 1771 runtime floor enforcement | P2: Cloud and Desktop setup paths remain major-only; do not merge until range-aware | static review; check:runtime PASS; check:codex-cloud PASS; ledger PASS; outstanding issues PASS; focused Vitest blocked by active Playwright lease | -| 2026-08-09 | cursor/therapy-card-densify-e975 | 3db839a6bb1f5b45fc55bb732d21b30551a506b0 | therapy search ResultCard densify (gap, tags, favourite, actions, match cells) | pass — denser cards; band gap fixed; single-row prioritized tags; heart top-right; 3-col actions; summarised cells | unit 35/35; verify:pr-local pass; ensure visual phone+desktop pass | +| 2026-08-09 | cursor/smarter-meds-search-9c1b | 74c3ea7706802925040b2c5603a7140a54dc9cd3 | medications-catalog-search typos brands | shipped catalog-local typo/brand search; no RAG | npm run test: 5899 passed | | 2026-08-09 | claude/m3-token-debt-262-261 | c6e1fe7fc42ec6f286eb5a3d8f7ddad7dfad2724 | design-system contract: raw padding/radius/line-height ratchets + type-step selection gate (#262 parts 2/3); closed #218/#270 | Authored and self-verified; PR #1780 open, auto-merge deliberately not armed (gate change). Baseline additive: all 15 pre-existing metrics and every debtByPath entry byte-identical; 94/94 new findings verified present at their cited line. Mutation-tested both halves of part 3 and three failure modes of part 2. | check:design-system-contract, check:icon-scale, check:type-scale, check:outstanding-issues, vitest design-system-contract-utils (31 passed), format:check whole-tree, verify:cheap (exit 1 from 5 pre-existing failures, none in this diff; 3 cleared by merging main, remaining 2 byte-identical to origin/main) | | 2026-08-09 | claude/documentviewer-nav-convergence-oddhjx | 1395d533cb13eadc705e47f76aa9f39a7a11c058 | DocumentViewer / in-page-nav convergence (#288): non-adoption decision recorded in docs/search-chrome-behaviour.md; merged duplicated visible-element predicate into resolveVisibleElement; new convergence guard test | Converged what was duplicated; DocumentReviewer header adoption declined on the merits with four blocking reasons recorded. No contract test edited. | verify:pr-local (546/547 files, 5883 tests pass; sole failure tests/pr-handoff-stop.test.ts reproduced on pristine origin/main), verify:phone-chrome (contracts 123 pass; focused Chromium 7 pass), contract set 12 files/151 tests pass, lint, typecheck, format | | 2026-08-09 | claude/disabled-button-accessibility-piclvr | 722abdb780c715c0a89df268ed48f6c741ffd569 | disabled-placeholder buttons -> aria-disabled + inert handler (25 sites, 13 components); controlDisabled/therapy recipe aria-disabled styling; require-button-wiring redundantDisabledPair gate; wiring-conventions contract rewrite (settles #291) | authored — PR #1778 opened | lint (uncached, exit 0); typecheck; test 5878 passed/1 pre-existing root-env failure in pr-handoff-stop; build; check:rag:fixtures 36 golden cases; prettier --check clean; verify:ui not run (no browser in container) | @@ -850,20 +850,27 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-09 | claude/m3-token-debt-262-261 | 7bac3bd762b381cb25c9b2a15ef3bb7223d15b16 | PR #1780 review-and-fix | fixed P2 ratchet bypasses (arbitrary-property classes, CSS-consumer exemption anti-rot, modern CSS zero units); Bugbot clean; merge-tree clean; required CI was green on prior tip | vitest design-system-contract-utils 32/32; check:design-system-contract; mutation CSS-exemption fail→restore; verify:cheap PASS (549 files / 5933 tests); verify:pr-local stages PASS (test flake in design-system-adoption timed out once then 51/51 + full test 549/549 + check:rag:fixtures PASS); no provider gates | | 2026-08-09 | cursor/dsm-search-header-fix-15d6 | df088c766f1761496189ec09146aa54c23b1c012 | dsm-search-header | pass: removed catalogue page strip; ribbon + category filter match target | vitest dsm-search-empty-state; npm test 5857 passed; lint; typecheck; ensure phone /dsm/search?q=Delirium | | 2026-08-09 | claude/m3-token-debt-262-261 | fe75e6acade008e68f953e235cc035f2e5d9d216 | PR #1780 review-and-fix | fixed P2 ratchet bypasses; synced origin/main (#1775); Bugbot clean; merge-tree clean | vitest design-system-contract-utils 32/32; check:design-system-contract; mutation CSS-exemption; verify:cheap PASS 549/5933; verify:pr-local stages PASS after adoption flake retest; check:rag:fixtures PASS; no provider gates | -| 2026-08-09 | cursor/therapy-card-densify-e975 | 52f07d49f89e6c786c624ccbd38ae552818a2071 | PR 1783 babysit | fixed review threads: TagRow +N clip, title/alias preview exclusion, preview field fallbacks; Copilot md grid kept; CI re-triggered after Copilot tip | npm test: 5958 passed / 4 skipped | +| 2026-08-09 | cursor/smarter-meds-search-9c1b | aced65e055892b0e2927b3999f95c6435102f610 | medications-catalog-search typos brands | main sync; catalog-local typo/brand search complete; no RAG | medications+route tests 49 passed; merge-tree clean | | 2026-08-09 | claude/in-page-nav-pr-3-i6gi8n | 6651feef4fab63f1181fba57908cb22e2932df3c | in-page-nav PR 3: convert /medications/[slug] (panel-swap) and /factsheets/[slug] (anchors) onto InPageNavHeader; record the differentials-presentations exception; delete orphaned SecondaryNavigation (#271) | converted 2 of 3 routes, 3rd recorded as a reasoned lasting exception; tocFor and SecondaryNavigation deleted; route-sections contract 7 -> 12 routes plus a panel-swap suite | verify:pr-local (1 pre-existing root-permission failure in pr-handoff-stop.test.ts, all else green); test 5932 passed; in-page-nav-route-sections 29 passed; verify:phone-chrome 3/4 stages (focused-browser blocked by #255 Chromium 1194 vs 1234); build + bundle-budget + rag:fixtures green; verify:ui not run (#255, delegated to CI) | +| 2026-08-09 | cursor/smarter-meds-search-9c1b (PR #1785) | 94dd432c0f64fd0593ea68d15aaa612240e7cd1d | PR #1785 unblock/fix | synced origin/main (behind-but-clean); merge-tree clean; prior tip CI green except PR mergeability DIRTY; review threads already cleared | merge-tree clean; behind 0; prior abb827b1 PR required+Production UI green; focused meds tests to re-run after sync | | 2026-08-09 | PR #1782 / cursor/fix-document-open-scroll-e5bf | 5709f2cc7a954197e02107c96d7896d8d13445c3 | document-viewer open-at-top | ship: remove chunk mount scrollIntoView so document opens stay at overview top | document-viewer-shell.dom 7 pass; document-section-summary.dom 8 pass; verify:pr-local dry-run | | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 98029875db7d640d3e699829249bb33892296bff | PR #1782 unblock | before: static-pr+coverage failed on stale adoption-manifest (document-viewer-shell testFiles drift), merge-tree clean 0 behind, auto-merge armed, 1 advisory CodeRabbit waitFor thread; after: regenerated adoption-manifest, hardened scroll negative assertion, pre-commit+handoff adoption sync to prevent recurrence; CodeRabbit dispositioned as fixed by sync assert | check:design-system-adoption PASS; vitest design-system-adoption+document-viewer-shell+docs-inventory 63/63 PASS; format; no provider-backed checks | | 2026-08-09 | cursor/fix-document-open-scroll-e5bf (PR #1782) | 86698228533ebe10452c10c1bd7a3e1610d891ae | PR #1782 unblock | merged origin/main (behind-but-clean); fixed static-pr TS2322 on document-viewer-shell chunk fixture; fixed Production UI DSM compare remove stall via location.assign + DOM proof; prior adoption-manifest drift already fixed | tsc clean for changed files; vitest document-viewer-shell+dsm-compare-remove+design-system-adoption 59/59 PASS; check:design-system-adoption PASS; format; no provider-backed checks | -| 2026-08-09 | codex/chat-mode-home-copy-9d76 | 1d32ce6f0a9f580eef55363de17e15723ae8d898 | PR #1790 babysit | MERGED via squash auto-merge as d812c769; same-mode focus fixed; Copilot aria dispositioned (drawer still Clinical Guide); CodeRabbit focus-steal follow-up in cursor/same-mode-focus-no-steal-6df8; reply API unavailable | PR required PASS; Production UI shards PASS; unit mode-menu-prefetch PASS | -| 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 (PR #1792) | 905caf2d8eeb5d74af4d8d90fef7e50f4cb78f13 | PR #1792 babysit | before: Production UI critical FAILED (TS5101 baseUrl in isolated Playwright tsconfig after Next 16.3 main sync); Lighthouse advisory ignored; merge-tree clean 0 behind. after: removed deprecated baseUrl from run-playwright + run-lighthouse-budget; root-relative @/* paths; regression guards in unit tests; no threads acted on | tsc isolated tsconfig: old baseUrl TS5101 exit 2, fixed exit 0; vitest test-runner-safety+check-lighthouse-budget 84/84 PASS; format; no provider-backed checks | +| 2026-08-09 | cursor/smarter-meds-search-9c1b (PR #1785) | b722c628ca05eb32190ac6355e8ee0537817621c | PR #1785 unblock/fix | synced origin/main (#1782); behind-but-clean DIRTY cleared; merge-tree clean; review threads clear; prior tip product CI green | merge-tree clean; behind 0; test:focused meds after sync | +| 2026-08-09 | cursor/therapy-card-densify-e975 | 3db839a6bb1f5b45fc55bb732d21b30551a506b0 | therapy search ResultCard densify (gap, tags, favourite, actions, match cells) | pass — denser cards; band gap fixed; single-row prioritized tags; heart top-right; 3-col actions; summarised cells | unit 35/35; verify:pr-local pass; ensure visual phone+desktop pass | +| 2026-08-09 | cursor/therapy-card-densify-e975 | 52f07d49f89e6c786c624ccbd38ae552818a2071 | PR 1783 babysit | fixed review threads: TagRow +N clip, title/alias preview exclusion, preview field fallbacks; Copilot md grid kept; CI re-triggered after Copilot tip | npm test: 5958 passed / 4 skipped | +| 2026-08-10 | cursor/smarter-meds-search-9c1b (PR #1785) | a4f57500f6b16a4616e1f84c126c2f787a40766b | PR #1785 unblock/fix | before: DIRTY/CONFLICTING behind-but-clean (merge-tree clean, behind 3/ahead 8); prior tip a4f57500 CI green; 0 unresolved threads → after: merged origin/main once (sync-only); merge-tree clean; behind 0; no CI/thread code fixes; focused meds tests 201 passed | git merge-tree clean; npm run format; npm run test:focused meds/route/universal-search 201 passed; no provider-backed checks run | +| 2026-08-10 | cursor/smarter-meds-search-9c1b (PR #1785) | 88dbdd80ede81ec6062ffebae79244703d495a99 | PR #1785 unblock/fix | before: BEHIND/MERGEABLE behind-but-clean (merge-tree clean, behind 2/ahead 9); tip 88dbdd80 required CI green; → after: late merged origin/main once (#1793/#1794); merge-tree clean; behind 0; no required-CI code fixes; no provider-backed checks | git merge-tree clean; npm run format; prior tip CI green; no provider-backed checks run | | 2026-08-10 | PR #1797 / claude/codex-m4a-retire-dead-type-8wq9ta | 6bf3c7b2a0600021290e165302fd07d721af6592 | retire the dead --text-2xl-compact type step (ledger #297): globals.css @theme, twMerge config, two test lists, the design-system-contract exemption, TOKENS.md/GATES.md | Executed the recorded next action on outstanding-issues #297. The step had zero class-utility and zero var(--text-*) consumers, so the deletion renders identically; UNUSED_TYPE_STEP_EXEMPTIONS is now empty and the declared-but-unconsumed gate holds the line with no carve-out. One test fixture using the token as a synthetic var() consumer was repointed at --text-2xl-minus. GATES.md corrected to eight non-standard steps; the 705-consumer total is unchanged because this step contributed 0. No clinical, RAG-ranking or operational risk paths touched (classifyPullRequestFiles: all false). | check:design-system-contract PASS (705 production files); check:type-scale --strict PASS; lint exit 0; typecheck exit 0; npm run build after rm -rf .next exit 0 (Compiled successfully in 63s); check:outstanding-issues PASS; verify:pr-local completed through typecheck then failed at test on a PRE-EXISTING root-permission failure in tests/pr-handoff-stop.test.ts that reproduces on clean d812c76 (5993 passed, 1 failed); build and check:rag:fixtures run/assessed separately. No UI gate: no rendered output can change. No provider-backed check run. | | 2026-08-10 | codex/visual-baseline-advisory-pr | 6bc57714c36bc6d027561bb8f5f8b00bb92524b2 | PR #1791 babysit unblock | fixed Production UI formulation Clear→Draft flake settle; classified visual drift vs non-drift failures | test:ci-workflows 263; classify-visual-baseline-outcome+ci-cache-safety 40 | +| 2026-08-10 | cursor/smarter-meds-search-9c1b (PR #1785) | 5cb0e11e077a3aaf5b8e4ea37b26ac72b0328997 | PR #1785 unblock/fix | before: Production UI (3) failed on service-detail scroll endpoint (remaining 67px) at 38b3bd0c; GitHub DIRTY behind-but-clean vs #1791. after: merged origin/main + re-scroll toPass fix in ui-tools service-detail test; threads untouched; do not merge | CI Production UI (3) logs; git merge-tree clean; prettier ui-tools; product fix in same tip commit as this row | +| 2026-08-09 | origin/pr/1686 | a5cce760d73bd174dba200b53852568dcdb9be0d | PR #1686 CI testing perfection and merged rollout reconciliation | Merged required CI was green, but hosted evidence confirmed P2 shard imbalance, duplicated critical coverage, net-negative 1.09 GB cache transport, inactive container revision enforcement, duplicated workflow/build/browser work, and missing local npm-ci selection. Fixed locally on current main; no P0/P1. | Hosted run 31285952061 inspected; focused Vitest 55 passed plus browser-preflight 12 passed; CI workflow suite 256 passed; typecheck passed; CI scope, verification plan, shard parity, gate manifest, action pins, npm-ci dry-run, docs and outstanding-issues guards passed; no Playwright/browser run or provider mutation. | +| 2026-08-10 | codex/ci-perfected-rollout-20260809 (PR #1789) | accbc7c6324b839112ff8df8f9b66d3557f2b98e | PR babysit | unblocked; merged origin/main (false-DIRTY behind-but-clean); fixed Codex P2 ui_changed for Playwright runner helpers; thread replied+resolved | ci-change-scope --self-test pass; merge-tree clean; no provider gates | +| 2026-08-10 | codex/ci-perfected-rollout-20260809 (PR #1789) | bf437370441c43a35ec63353642b0180ba5beba6 | PR babysit | late sync: merged origin/main (#1793/#1794); behind-but-clean; prior tip CI green; no code fixes | merge-tree clean; format clean; prior tip PR required pass; no provider gates | +| 2026-08-09 | codex/chat-mode-home-copy-9d76 | 1d32ce6f0a9f580eef55363de17e15723ae8d898 | PR #1790 babysit | MERGED via squash auto-merge as d812c769; same-mode focus fixed; Copilot aria dispositioned (drawer still Clinical Guide); CodeRabbit focus-steal follow-up in cursor/same-mode-focus-no-steal-6df8; reply API unavailable | PR required PASS; Production UI shards PASS; unit mode-menu-prefetch PASS | +| 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 (PR #1792) | 905caf2d8eeb5d74af4d8d90fef7e50f4cb78f13 | PR #1792 babysit | before: Production UI critical FAILED (TS5101 baseUrl in isolated Playwright tsconfig after Next 16.3 main sync); Lighthouse advisory ignored; merge-tree clean 0 behind. after: removed deprecated baseUrl from run-playwright + run-lighthouse-budget; root-relative @/* paths; regression guards in unit tests; no threads acted on | tsc isolated tsconfig: old baseUrl TS5101 exit 2, fixed exit 0; vitest test-runner-safety+check-lighthouse-budget 84/84 PASS; format; no provider-backed checks | | 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 | b82ae6fe80cfc5e4dac139383230d5a8fcb62b68 | Run PR sweep | fix: Unit coverage tsconfig contract aligned to #1798 ignoreDeprecations; merged origin/main | vitest test-runner-safety+check-lighthouse-budget 84 passed; Unit coverage was FAIL on 3f2aae3a | | 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 | f0dc8e922d0cf05ff3d9f7c2c5f4d203c8449794 | Run PR sweep | fix: Unit coverage tsconfig contract aligned to #1798 ignoreDeprecations; merged origin/main | vitest test-runner-safety+check-lighthouse-budget 84 passed; Unit coverage was FAIL on 3f2aae3a | | 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 | a6a5e4cd59352244163a5d6d5439c2bc40a7ff95 | Run PR sweep | fix: Unit coverage tsconfig contract aligned to #1798 ignoreDeprecations; merged origin/main | vitest test-runner-safety+check-lighthouse-budget 84 passed; Unit coverage was FAIL on 3f2aae3a | | 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 | f8d7ef3faa397de7c9aecc61a66ec5428a3eeed3 | Run PR sweep | fix: Unit coverage tsconfig contract aligned to #1798 ignoreDeprecations; merged origin/main | vitest test-runner-safety+check-lighthouse-budget 84 passed; Unit coverage was FAIL on 3f2aae3a | | 2026-08-10 | cursor/same-mode-focus-no-steal-6df8 | e6eea4d9b677de6919165753b9ceae059bac1190 | Run PR sweep | fix: Unit coverage tsconfig contract aligned to #1798 ignoreDeprecations; merged origin/main | vitest test-runner-safety+check-lighthouse-budget 84 passed; Unit coverage was FAIL on 3f2aae3a | -| 2026-08-09 | origin/pr/1686 | a5cce760d73bd174dba200b53852568dcdb9be0d | PR #1686 CI testing perfection and merged rollout reconciliation | Merged required CI was green, but hosted evidence confirmed P2 shard imbalance, duplicated critical coverage, net-negative 1.09 GB cache transport, inactive container revision enforcement, duplicated workflow/build/browser work, and missing local npm-ci selection. Fixed locally on current main; no P0/P1. | Hosted run 31285952061 inspected; focused Vitest 55 passed plus browser-preflight 12 passed; CI workflow suite 256 passed; typecheck passed; CI scope, verification plan, shard parity, gate manifest, action pins, npm-ci dry-run, docs and outstanding-issues guards passed; no Playwright/browser run or provider mutation. | -| 2026-08-10 | codex/ci-perfected-rollout-20260809 (PR #1789) | accbc7c6324b839112ff8df8f9b66d3557f2b98e | PR babysit | unblocked; merged origin/main (false-DIRTY behind-but-clean); fixed Codex P2 ui_changed for Playwright runner helpers; thread replied+resolved | ci-change-scope --self-test pass; merge-tree clean; no provider gates | -| 2026-08-10 | codex/ci-perfected-rollout-20260809 (PR #1789) | bf437370441c43a35ec63353642b0180ba5beba6 | PR babysit | late sync: merged origin/main (#1793/#1794); behind-but-clean; prior tip CI green; no code fixes | merge-tree clean; format clean; prior tip PR required pass; no provider gates | diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index e453b66ee0..7ff245ab52 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -16,9 +16,10 @@ import { rowGovernance, rowToMedicationRecord, } from "@/lib/medication-records"; +import { medicationCatalogInterpretation, searchMedicationCatalog } from "@/lib/medication-query"; import { + medicationBrandNames, medicationToSearchResult, - rankMedicationRecords, type MedicationRecord, type MedicationSearchMatch, } from "@/lib/medications"; @@ -44,8 +45,20 @@ const medicationListQuerySchema = z.object({ // `fields=index` strips the heavy per-record content (stats/sections/quick are // ~99% of the ~3.4 MB catalog) for callers that only need identity-level -// ranking, e.g. the answer surface's cross-mode links. The records keep the -// full MedicationRecord shape so rankers and badge helpers work unchanged. +// ranking, e.g. the answer surface's cross-mode links. Brand Names rows are +// retained so prescription-name search still scores at name weight. +function brandIdentitySections(record: MedicationRecord): MedicationRecord["sections"] { + const brands = medicationBrandNames(record); + if (!brands.length) return []; + return [ + { + title: "Formulation & Access", + type: "form", + rows: [{ key: "Brand Names", val: brands.join(", ") }], + }, + ]; +} + function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { return records.map((record) => ({ slug: record.slug, @@ -57,11 +70,27 @@ function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { tag: record.tag, schedule: record.schedule, stats: [], - sections: [], + sections: brandIdentitySections(record), quick: [], })); } +function rankCatalogMatches(records: MedicationRecord[], q: string, limit: number, projectIndex = false) { + const { matches, analysis } = searchMedicationCatalog(records, q, limit); + // Rank on full records for vocabulary, but serialize the slim identity shape + // when fields=index so matches do not reintroduce stats/sections/quick. + const serialized = projectIndex + ? matches.map((match) => ({ + ...match, + medication: toIndexRecords([match.medication])[0]!, + })) + : matches; + return { + matches: matchesPayload(serialized), + interpretation: medicationCatalogInterpretation(analysis), + }; +} + function medicationResponse(payload: Record, options: { request?: Request; fixture?: boolean } = {}) { return NextResponse.json(payload, { headers: fixtureResponseHeaders(options.request, options) }); } @@ -108,12 +137,16 @@ function publicGovernance(records: MedicationRecord[]) { } function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") { - const records = fields === "index" ? publicIndexRecords() : defaultMedicationRecords(); - const governance = publicGovernance(records); - const matches = q ? rankMedicationRecords(records, q, limit) : undefined; + // Rank against the full snapshot even for fields=index so typo/brand vocabulary + // is complete; response records still use the slim identity projection. + const fullRecords = defaultMedicationRecords(); + const records = fields === "index" ? publicIndexRecords() : fullRecords; + const governance = publicGovernance(fullRecords); + const ranked = q ? rankCatalogMatches(fullRecords, q, limit, fields === "index") : undefined; return { records, - matches: matches ? matchesPayload(matches) : undefined, + matches: ranked?.matches, + interpretation: ranked?.interpretation, total: records.length, governance, }; @@ -163,10 +196,12 @@ export async function GET(request: Request) { const fullRecords = rows.map(rowToMedicationRecord); const records = fields === "index" ? toIndexRecords(fullRecords) : fullRecords; const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); + const ranked = q ? rankCatalogMatches(fullRecords, q, limit, fields === "index") : undefined; return medicationResponse({ records, - matches: q ? matchesPayload(rankMedicationRecords(records, q, limit)) : undefined, + matches: ranked?.matches, + interpretation: ranked?.interpretation, total: rows.length, governance: governanceBySlug, }); diff --git a/src/lib/medication-entities.ts b/src/lib/medication-entities.ts index 160a6f3f67..20dc747367 100644 --- a/src/lib/medication-entities.ts +++ b/src/lib/medication-entities.ts @@ -330,8 +330,10 @@ zuclopenthixol decanoate // This is deliberately a compact, static safety catalogue derived from the // medication snapshot. Importing the full snapshot would add several megabytes -// of presentation data to the answer path. Aliases only establish identity for -// safety checks; they do not expand retrieval queries or alter ranking. +// of presentation data to the answer path. Aliases establish identity for +// safety checks and are not used to expand RAG retrieval queries. Medication +// catalog search may borrow this list for brand↔generic recall via +// medication-query.ts only. const medicationIdentityGroups = [ ["adrenaline", "epinephrine"], ["aripiprazole", "abilify"], diff --git a/src/lib/medication-query.ts b/src/lib/medication-query.ts new file mode 100644 index 0000000000..c063a2a968 --- /dev/null +++ b/src/lib/medication-query.ts @@ -0,0 +1,266 @@ +// Medications-catalog query understanding: typo correction, brand↔generic +// expansion, and conservative near-miss matching for /api/medications and the +// medications universal-search domain. Kept separate from clinical-search / +// RAG so catalog recall can grow without changing retrieval analysis. + +import { medicationAliasesForEntity, medicationEntitiesInText } from "@/lib/medication-entities"; +import { + medicationBrandNames, + normalizeSearchText, + rankMedicationRecords, + type MedicationRecord, + type MedicationSearchMatch, +} from "@/lib/medications"; + +export type MedicationCatalogCorrection = { + from: string; + to: string; +}; + +export type MedicationCatalogQueryAnalysis = { + originalQuery: string; + correctedQuery: string; + corrections: MedicationCatalogCorrection[]; + expansions: string[]; +}; + +// Curated psychotropic / common prescribing typos. Local to the catalog path so +// expanding this map cannot change RAG query analysis in clinical-search. +const medicationTypoCorrections = new Map([ + ["sertaline", "sertraline"], + ["sertralne", "sertraline"], + ["sertralin", "sertraline"], + ["olanzpine", "olanzapine"], + ["olanzapin", "olanzapine"], + ["quietapine", "quetiapine"], + ["quetiapin", "quetiapine"], + ["quetiapne", "quetiapine"], + ["risperdone", "risperidone"], + ["risperidon", "risperidone"], + ["aripiprazol", "aripiprazole"], + ["aripiprazloe", "aripiprazole"], + ["clozapin", "clozapine"], + ["clozapene", "clozapine"], + ["clozapinw", "clozapine"], + ["mirtazpine", "mirtazapine"], + ["mirtazapin", "mirtazapine"], + ["venlafaxne", "venlafaxine"], + ["duloxetin", "duloxetine"], + ["escitaloprame", "escitalopram"], + ["fluoxetin", "fluoxetine"], + ["paroxetin", "paroxetine"], + ["haloperidl", "haloperidol"], + ["lorazapam", "lorazepam"], + ["diazapam", "diazepam"], + ["valporate", "valproate"], + ["valproat", "valproate"], + ["lithum", "lithium"], + ["acamprosat", "acamprosate"], + ["camprl", "campral"], + ["zolof", "zoloft"], + ["zyprex", "zyprexa"], + ["seroqel", "seroquel"], +]); + +const fuzzyMinTokenLength = 6; +const maxExpansions = 16; + +function queryTokens(query: string): string[] { + return normalizeSearchText(query) + .split(/\s+/) + .filter((token) => token.length > 0); +} + +function addNormalizedTokens(target: Set, value: string) { + for (const token of queryTokens(value)) { + if (token.length > 1) target.add(token); + } +} + +/** Vocabulary of generic names, slugs, brand tokens, and safety-identity aliases. */ +export function buildMedicationCatalogVocabulary(records: MedicationRecord[]): Set { + const vocab = new Set(); + for (const record of records) { + addNormalizedTokens(vocab, record.name); + addNormalizedTokens(vocab, record.slug.replace(/-/g, " ")); + for (const brand of medicationBrandNames(record)) { + addNormalizedTokens(vocab, brand); + } + for (const alias of medicationAliasesForEntity(record.name)) { + addNormalizedTokens(vocab, alias); + } + } + // Also index identity aliases that may not share a catalog name token + // (e.g. "zyprexa" when only formulation-named records exist). + for (const record of records) { + for (const brand of medicationBrandNames(record)) { + for (const alias of medicationAliasesForEntity(brand)) { + addNormalizedTokens(vocab, alias); + } + } + } + return vocab; +} + +/** True when Levenshtein distance is exactly 1 (substitution, insertion, or deletion). */ +export function isEditDistanceOne(left: string, right: string): boolean { + if (left === right) return false; + const a = left; + const b = right; + const lenA = a.length; + const lenB = b.length; + if (Math.abs(lenA - lenB) > 1) return false; + + let i = 0; + let j = 0; + let edits = 0; + while (i < lenA && j < lenB) { + if (a[i] === b[j]) { + i += 1; + j += 1; + continue; + } + edits += 1; + if (edits > 1) return false; + if (lenA > lenB) i += 1; + else if (lenB > lenA) j += 1; + else { + i += 1; + j += 1; + } + } + if (i < lenA || j < lenB) edits += 1; + return edits === 1; +} + +function uniqueEditDistanceOneMatch(token: string, vocabulary: Set): string | undefined { + if (token.length < fuzzyMinTokenLength || vocabulary.has(token)) return undefined; + let match: string | undefined; + for (const candidate of vocabulary) { + if (Math.abs(candidate.length - token.length) > 1) continue; + if (!isEditDistanceOne(token, candidate)) continue; + if (match && match !== candidate) return undefined; + match = candidate; + } + return match; +} + +function brandOrNameExpansions(token: string, records: MedicationRecord[]): string[] { + const expansions: string[] = []; + const seen = new Set(); + const push = (value: string) => { + const normalized = normalizeSearchText(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + expansions.push(normalized); + }; + + for (const entity of medicationEntitiesInText(token)) { + push(entity); + for (const alias of medicationAliasesForEntity(entity)) push(alias); + } + + const needle = normalizeSearchText(token); + if (!needle) return expansions; + + for (const record of records) { + const name = normalizeSearchText(record.name); + const slug = normalizeSearchText(record.slug.replace(/-/g, " ")); + const brands = medicationBrandNames(record).map((brand) => normalizeSearchText(brand)); + const hitsName = name === needle || name.split(/\s+/).includes(needle) || slug.split(/\s+/).includes(needle); + const hitsBrand = brands.some((brand) => brand === needle || brand.split(/\s+/).includes(needle)); + if (!hitsName && !hitsBrand) continue; + // Expand to the canonical identity only. Sibling brands must not enter the + // expanded content lane — substring includes() would let "eleva" match + // "elevated" and pull unrelated records into brand queries like "zoloft". + push(record.name); + push(record.slug.replace(/-/g, " ")); + for (const alias of medicationAliasesForEntity(record.name)) push(alias); + } + + return expansions; +} + +/** + * Correct typos and collect brand/generic expansions for medication catalog ranking. + * Does not call clinical-search or RAG helpers. + */ +export function analyzeMedicationCatalogQuery( + query: string, + records: MedicationRecord[], +): MedicationCatalogQueryAnalysis { + const originalQuery = query.trim(); + if (!originalQuery) { + return { originalQuery: "", correctedQuery: "", corrections: [], expansions: [] }; + } + + const vocabulary = buildMedicationCatalogVocabulary(records); + const rawTokens = originalQuery.split(/\s+/).filter(Boolean); + const corrections: MedicationCatalogCorrection[] = []; + const correctedTokens: string[] = []; + + for (const raw of rawTokens) { + const lower = raw.toLowerCase(); + const normalized = normalizeSearchText(raw); + const mapped = medicationTypoCorrections.get(lower) ?? medicationTypoCorrections.get(normalized); + if (mapped && mapped !== normalized && mapped !== lower) { + corrections.push({ from: lower, to: mapped }); + correctedTokens.push(mapped); + continue; + } + const fuzzy = normalized ? uniqueEditDistanceOneMatch(normalized, vocabulary) : undefined; + if (fuzzy) { + corrections.push({ from: normalized, to: fuzzy }); + correctedTokens.push(fuzzy); + continue; + } + correctedTokens.push(raw); + } + + const correctedQuery = correctedTokens.join(" "); + const baseTokens = new Set(queryTokens(correctedQuery)); + const expansionSeen = new Set(baseTokens); + const expansions: string[] = []; + + for (const token of queryTokens(correctedQuery)) { + for (const term of brandOrNameExpansions(token, records)) { + for (const piece of queryTokens(term)) { + if (piece.length < 2 || expansionSeen.has(piece)) continue; + expansionSeen.add(piece); + expansions.push(piece); + if (expansions.length >= maxExpansions) { + return { originalQuery, correctedQuery, corrections, expansions }; + } + } + } + } + + return { originalQuery, correctedQuery, corrections, expansions }; +} + +export function searchMedicationCatalog( + records: MedicationRecord[], + query: string, + limit = 50, +): { + matches: MedicationSearchMatch[]; + analysis: MedicationCatalogQueryAnalysis; +} { + const analysis = analyzeMedicationCatalogQuery(query, records); + const matches = analysis.correctedQuery + ? rankMedicationRecords(records, analysis.correctedQuery, limit, analysis.expansions) + : []; + return { matches, analysis }; +} + +export function medicationCatalogInterpretation(analysis: MedicationCatalogQueryAnalysis) { + if (!analysis.corrections.length && !analysis.expansions.length) return undefined; + return { + correctedQuery: + analysis.corrections.length && analysis.correctedQuery !== analysis.originalQuery + ? analysis.correctedQuery + : undefined, + corrections: analysis.corrections.length ? analysis.corrections : undefined, + appliedExpansions: analysis.expansions.length ? analysis.expansions : undefined, + }; +} diff --git a/src/lib/medications.ts b/src/lib/medications.ts index 7afd5c557b..ba95afb97f 100644 --- a/src/lib/medications.ts +++ b/src/lib/medications.ts @@ -118,6 +118,58 @@ function quickValue(record: MedicationRecord, labelIncludes: string) { return row?.value?.trim() ?? ""; } +/** + * Parse a Brand Names cell into identity tokens only. + * Strip parenthetical annotations before splitting so internal "/" or "," + * (e.g. "Benadryl (Sleep/Allergy formulations)") cannot fabricate brands, and + * drop sentence-level prose after the brand list ("Bactrim, Resprim. Available…"). + */ +export function parseMedicationBrandNameList(value: string): string[] { + const withoutMarkup = value.replace(/\*\*/g, "").trim(); + if (!withoutMarkup) return []; + + // Annotations first — then list separators — so slash/comma inside "(…)" never tokenize. + const withoutAnnotations = withoutMarkup.replace(/\([^)]*\)/g, " "); + // Brand lists are comma/semicolon separated; truncate trailing prose after a sentence end. + const brandListHead = withoutAnnotations.split(/\.\s+(?=[A-Z])/)[0] ?? withoutAnnotations; + + const brands: string[] = []; + const seen = new Set(); + for (const part of brandListHead.split(/[,;/]/g)) { + const brand = part.replace(/\s+/g, " ").trim(); + if (brand.length < 2) continue; + // Reject leftover narrative fragments that are not brand-like (too many words). + if (brand.split(/\s+/).length > 4) continue; + const key = brand.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + brands.push(brand); + } + return brands; +} + +/** Brand names from the Formulation & Access "Brand Names" row (prescription names). */ +export function medicationBrandNames(record: MedicationRecord): string[] { + const values = record.sections + .filter((section) => section.type === "form") + .flatMap((section) => section.rows) + .filter((row) => /brand\s*names?/i.test(row.key)) + .map((row) => row.val.replace(/\*\*/g, "").trim()) + .filter(Boolean); + + const brands: string[] = []; + const seen = new Set(); + for (const value of values) { + for (const brand of parseMedicationBrandNameList(value)) { + const key = brand.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + brands.push(brand); + } + } + return brands; +} + export function medicationSearchText(record: MedicationRecord) { const sectionText = record.sections .flatMap((section) => [section.title, ...section.rows.flatMap((row) => [row.key, row.val, ...(row.tags ?? [])])]) @@ -133,6 +185,7 @@ export function medicationSearchText(record: MedicationRecord) { record.category, record.tag, record.schedule, + medicationBrandNames(record).join(" "), sectionText, quickText, statText, @@ -288,6 +341,13 @@ export function rankMedicationRecords( weight: 8, text: (medication) => normalizeSearchText(`${medication.name} ${medication.slug}`), }, + { + // Prescription / trade names — same weight family as generic name so brand + // queries (Campral, Zoloft) are not demoted to weak content hits. + id: "brands", + weight: 8, + text: (medication) => normalizeSearchText(medicationBrandNames(medication).join(" ")), + }, { id: "taxonomy", weight: 3, @@ -300,11 +360,20 @@ export function rankMedicationRecords( fullText: medicationSearchText, contentWeight: 2, compactBonus: 6, - compactExtraText: (medication) => normalizeSearchText(medication.name), + compactExtraText: (medication) => + normalizeSearchText([medication.name, ...medicationBrandNames(medication)].join(" ")), phraseBonus: 4, - exactValues: (medication) => [normalizeSearchText(medication.name), normalizeSearchText(medication.slug)], + exactValues: (medication) => [ + normalizeSearchText(medication.name), + normalizeSearchText(medication.slug), + ...medicationBrandNames(medication).map((brand) => normalizeSearchText(brand)), + ], exactBonus: 10, - prefixValues: (medication) => [normalizeSearchText(medication.name), normalizeSearchText(medication.slug)], + prefixValues: (medication) => [ + normalizeSearchText(medication.name), + normalizeSearchText(medication.slug), + ...medicationBrandNames(medication).map((brand) => normalizeSearchText(brand)), + ], prefixBonus: 5, expandTokens: expansions.length ? (terms) => [...terms, ...expansions] : undefined, limit, @@ -314,6 +383,7 @@ export function rankMedicationRecords( score, reasons: [ signals.fields.name ? "name" : "", + signals.fields.brands ? "brand" : "", signals.prefix ? "name prefix" : "", signals.compact ? "exact name" : "", signals.fields.taxonomy ? "class/category" : "", diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index c97350f519..e65fe512a9 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -12,6 +12,7 @@ import { dsmDiagnosisSummary, rankDsmDiagnoses } from "@/lib/dsm"; import { formRecords, rankFormRecords, type FormRecord } from "@/lib/forms"; import { rowToMedicationRecord } from "@/lib/medication-records"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; +import { analyzeMedicationCatalogQuery } from "@/lib/medication-query"; import { medicationIndication, rankMedicationRecords, type MedicationRecord } from "@/lib/medications"; import { loadOwnerCatalogue } from "@/lib/owner-catalogue-cache"; import { searchChunksWithTelemetry } from "@/lib/rag/rag"; @@ -257,7 +258,19 @@ async function searchMedicationsDomain(args: ResolvedSearchArgs): Promise + // Catalog-local typo/brand understanding (not clinical-search / RAG analysis). + // Prefer catalog corrections when they change the query; otherwise keep the + // shared clinical-search correction (e.g. monitring → monitoring) and its + // expansions so the medications domain does not ignore the federated base. + const catalogAnalysis = analyzeMedicationCatalogQuery(args.query, records); + const catalogCorrected = + catalogAnalysis.corrections.length > 0 && + catalogAnalysis.correctedQuery.trim().toLowerCase() !== catalogAnalysis.originalQuery.trim().toLowerCase() + ? catalogAnalysis.correctedQuery + : ""; + const rankingQuery = catalogCorrected || args.baseQuery; + const rankingExpansions = Array.from(new Set([...catalogAnalysis.expansions, ...args.expansions])); + return rankMedicationRecords(records, rankingQuery, args.limitPerDomain, rankingExpansions).map((match) => medicationItem(match.medication, match.score), ); } diff --git a/tests/medications-route.test.ts b/tests/medications-route.test.ts index 7d9aa281cb..d32964a03c 100644 --- a/tests/medications-route.test.ts +++ b/tests/medications-route.test.ts @@ -209,7 +209,13 @@ describe("medications API", () => { const response = await GET(request("/api/medications?fields=index")); const payload = (await response.json()) as { - records: Array<{ slug: string; name: string; stats: unknown[]; sections: unknown[]; quick: unknown[] }>; + records: Array<{ + slug: string; + name: string; + stats: unknown[]; + sections: Array<{ type: string; rows: Array<{ key: string }> }>; + quick: unknown[]; + }>; }; expect(response.status).toBe(200); @@ -217,9 +223,71 @@ describe("medications API", () => { expect(acamprosate?.name).toBe("Acamprosate"); expect( payload.records.every( - (record) => record.stats.length === 0 && record.sections.length === 0 && record.quick.length === 0, + (record) => + record.stats.length === 0 && + record.quick.length === 0 && + record.sections.every( + (section) => section.type === "form" && section.rows.every((row) => /brand\s*names?/i.test(row.key)), + ), ), ).toBe(true); + expect(acamprosate?.sections[0]?.rows[0]?.key).toMatch(/brand\s*names?/i); + }); + + it("ranks brand names and typo corrections on the list endpoint", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/medications/route"); + + const brandResponse = await GET(request("/api/medications?q=campral&limit=5")); + const brandPayload = (await brandResponse.json()) as { + matches?: Array<{ medication: { slug: string }; reasons: string[] }>; + }; + expect(brandResponse.status).toBe(200); + expect(brandPayload.matches?.[0]?.medication.slug).toBe("acamprosate"); + expect(brandPayload.matches?.[0]?.reasons).toContain("brand"); + + const typoResponse = await GET(request("/api/medications?q=sertaline&limit=5")); + const typoPayload = (await typoResponse.json()) as { + matches?: Array<{ medication: { slug: string } }>; + interpretation?: { correctedQuery?: string; corrections?: Array<{ from: string; to: string }> }; + }; + expect(typoResponse.status).toBe(200); + expect(typoPayload.matches?.[0]?.medication.slug).toBe("sertraline"); + expect(typoPayload.interpretation?.correctedQuery).toBe("sertraline"); + expect(typoPayload.interpretation?.corrections).toContainEqual({ from: "sertaline", to: "sertraline" }); + }); + + it("projects matched medications to the index shape when fields=index&q is set", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/medications/route"); + + const response = await GET(request("/api/medications?fields=index&q=campral&limit=5")); + const payload = (await response.json()) as { + matches?: Array<{ + medication: { + slug: string; + stats: unknown[]; + quick: unknown[]; + sections: Array<{ type: string; rows: Array<{ key: string }> }>; + }; + }>; + }; + + expect(response.status).toBe(200); + expect(payload.matches?.length).toBeGreaterThan(0); + expect( + payload.matches?.every( + (match) => + match.medication.stats.length === 0 && + match.medication.quick.length === 0 && + match.medication.sections.every( + (section) => section.type === "form" && section.rows.every((row) => /brand\s*names?/i.test(row.key)), + ), + ), + ).toBe(true); + expect(payload.matches?.[0]?.medication.slug).toBe("acamprosate"); }); it("serves curated public records for unauthenticated list requests outside demo mode", async () => { diff --git a/tests/medications.test.ts b/tests/medications.test.ts index 22f075e1de..5630168198 100644 --- a/tests/medications.test.ts +++ b/tests/medications.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from "vitest"; import { getMedicationRecord, loadMedicationSnapshot } from "@/lib/medication-snapshot"; +import { analyzeMedicationCatalogQuery, isEditDistanceOne, searchMedicationCatalog } from "@/lib/medication-query"; import { firstClinicalSentence, medicationActionDetail, + medicationBrandNames, medicationHeroMetrics, medicationIndication, medicationToSearchResult, + parseMedicationBrandNameList, rankMedicationRecords, shortValue, type MedicationRecord, @@ -60,6 +63,16 @@ describe("medications catalogue", () => { expect(matches[0]?.reasons).toContain("name prefix"); }); + it("scores prescription brand names at name weight", () => { + const records = loadMedicationSnapshot(); + const acamprosate = records.find((record) => record.slug === "acamprosate"); + expect(medicationBrandNames(acamprosate!).map((brand) => brand.toLowerCase())).toContain("campral"); + + const matches = rankMedicationRecords(records, "campral", 5); + expect(matches[0]?.medication.slug).toBe("acamprosate"); + expect(matches[0]?.reasons).toContain("brand"); + }); + it("exposes prescribing summary fields for search results", () => { const record = getMedicationRecord("acamprosate"); expect(record).toBeTruthy(); @@ -69,6 +82,93 @@ describe("medications catalogue", () => { }); }); +describe("medication catalog query understanding", () => { + it("corrects common generic typos before ranking", () => { + const records = loadMedicationSnapshot(); + const { matches, analysis } = searchMedicationCatalog(records, "sertaline", 5); + expect(analysis.corrections).toContainEqual({ from: "sertaline", to: "sertraline" }); + expect(matches[0]?.medication.slug).toBe("sertraline"); + }); + + it("expands brand identity aliases for catalog recall", () => { + const records = loadMedicationSnapshot(); + const analysis = analyzeMedicationCatalogQuery("zyprexa", records); + expect(analysis.expansions.some((term) => term.includes("olanzapine"))).toBe(true); + + const { matches } = searchMedicationCatalog(records, "zyprexa", 5); + expect(matches.some((match) => match.medication.slug.includes("olanzapine"))).toBe(true); + expect(matches[0]?.reasons.some((reason) => reason === "brand" || reason === "name")).toBe(true); + }); + + it("applies conservative unique edit-distance-1 corrections only", () => { + expect(isEditDistanceOne("sertaline", "sertraline")).toBe(true); + expect(isEditDistanceOne("sertralne", "sertraline")).toBe(true); + expect(isEditDistanceOne("sertalin", "sertraline")).toBe(false); + expect(isEditDistanceOne("abc", "xyz")).toBe(false); + + const records = loadMedicationSnapshot(); + // Short tokens must not invent corrections. + const short = analyzeMedicationCatalogQuery("dose", records); + expect(short.corrections).toHaveLength(0); + + // Ambiguous/non-unique near-misses must not invent a medication. + const nonsense = analyzeMedicationCatalogQuery("zzzzzz", records); + expect(nonsense.corrections).toHaveLength(0); + expect(searchMedicationCatalog(records, "zzzzzz", 5).matches).toHaveLength(0); + }); + + it("keeps the mid-word substring guard after query normalization", () => { + const records = loadMedicationSnapshot(); + const { matches } = searchMedicationCatalog(records, "renal dose", 10); + const adrenaline = matches.find((match) => match.medication.slug.includes("adrenaline")); + expect(adrenaline?.reasons ?? []).not.toContain("name"); + }); + + it("does not expand sibling brands into the content lane for brand queries", () => { + const records = loadMedicationSnapshot(); + const analysis = analyzeMedicationCatalogQuery("zoloft", records); + // Canonical identity (sertraline) is fine; other brands on that row (e.g. Eleva) + // must not become expansion tokens — "eleva" ⊆ "elevated" polluted recall. + expect(analysis.expansions.some((term) => term.includes("sertraline"))).toBe(true); + expect(analysis.expansions).not.toContain("eleva"); + + const { matches } = searchMedicationCatalog(records, "zoloft", 50); + expect(matches[0]?.medication.slug).toBe("sertraline"); + expect(matches[0]?.reasons).toContain("brand"); + // Before the fix eleva⊂elevated content hits pulled amiodarone/atorvastatin/iron + // into an ~18-record result set. Canonical "sertraline" content mentions on + // other SSRIs may still appear at low score — that is intentional. + expect(matches.some((match) => /amiodarone|atorvastatin|^iron/.test(match.medication.slug))).toBe(false); + expect(matches.filter((match) => match.reasons.includes("brand")).map((m) => m.medication.slug)).toEqual([ + "sertraline", + ]); + }); + + it("parses brand lists without annotation fragments or trailing prose", () => { + expect(parseMedicationBrandNameList("Benadryl (Sleep/Allergy formulations), Snuza")).toEqual(["Benadryl", "Snuza"]); + expect(parseMedicationBrandNameList("Bactrim, Resprim. Available as Single Strength tablet")).toEqual([ + "Bactrim", + "Resprim", + ]); + // Annotation tokens must not enter the fuzzy vocabulary as brands. + const allergyVocabHit = buildRecord({ + slug: "diphenhydramine", + name: "Diphenhydramine", + sections: [ + { + title: "Formulation & Access", + type: "form", + rows: [{ key: "Brand Names", val: "Benadryl (Sleep/Allergy formulations), Snuza" }], + }, + ], + }); + const brands = medicationBrandNames(allergyVocabHit).map((brand) => brand.toLowerCase()); + expect(brands).toEqual(["benadryl", "snuza"]); + expect(brands).not.toContain("allergy"); + expect(brands).not.toContain("sleep"); + }); +}); + describe("medication action tone", () => { it("marks quick avoid guidance as danger", () => { const record = buildRecord({ diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 75228fa4a2..b4ef44d25c 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -15,7 +15,8 @@ import { demoAnswer, demoDocuments, demoSummary, getDemoDocument, getDemoDocumen import { formRecords } from "../src/lib/forms"; import { deriveGovernanceFromSections } from "../src/lib/medication-records"; import { getMedicationRecord, loadMedicationSnapshot } from "../src/lib/medication-snapshot"; -import { medicationToSearchResult, rankMedicationRecords, type MedicationRecord } from "../src/lib/medications"; +import { searchMedicationCatalog } from "../src/lib/medication-query"; +import { medicationToSearchResult, type MedicationRecord } from "../src/lib/medications"; import { serviceRecords } from "../src/lib/services"; import { recentQueryStorageKey } from "../src/lib/recent-query-storage"; @@ -302,19 +303,27 @@ async function blockExternalRequests(page: Page) { } function medicationIndexRecords(records: MedicationRecord[]): MedicationRecord[] { - return records.map((record) => ({ - slug: record.slug, - name: record.name, - class: record.class, - subclass: record.subclass, - category: record.category, - accent: record.accent, - tag: record.tag, - schedule: record.schedule, - stats: [], - sections: [], - quick: [], - })); + return records.map((record) => { + const brandRows = record.sections + .filter((section) => section.type === "form") + .flatMap((section) => section.rows) + .filter((row) => /brand\s*names?/i.test(row.key)); + return { + slug: record.slug, + name: record.name, + class: record.class, + subclass: record.subclass, + category: record.category, + accent: record.accent, + tag: record.tag, + schedule: record.schedule, + stats: [], + sections: brandRows.length + ? [{ title: "Formulation & Access", type: "form", rows: brandRows.map((row) => ({ ...row })) }] + : [], + quick: [], + }; + }); } async function mockDemoApi(page: Page, options: MockDemoApiOptions = {}) { @@ -367,16 +376,26 @@ async function mockDemoApi(page: Page, options: MockDemoApiOptions = {}) { const limit = Number(url.searchParams.get("limit") ?? "50"); const fullRecords = loadMedicationSnapshot(); const records = url.searchParams.get("fields") === "index" ? medicationIndexRecords(fullRecords) : fullRecords; - const matches = query ? rankMedicationRecords(records, query, limit) : undefined; + const ranked = query ? searchMedicationCatalog(fullRecords, query, limit) : undefined; await route.fulfill({ json: { records, - matches: matches?.map((match) => ({ + matches: ranked?.matches.map((match) => ({ medication: match.medication, result: medicationToSearchResult(match), score: match.score, reasons: match.reasons, })), + interpretation: ranked + ? { + correctedQuery: + ranked.analysis.corrections.length && ranked.analysis.correctedQuery !== ranked.analysis.originalQuery + ? ranked.analysis.correctedQuery + : undefined, + corrections: ranked.analysis.corrections.length ? ranked.analysis.corrections : undefined, + appliedExpansions: ranked.analysis.expansions.length ? ranked.analysis.expansions : undefined, + } + : undefined, total: records.length, governance: {}, demoMode: true, diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 14748ddf6c..6779b99b79 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -2432,15 +2432,16 @@ test.describe("Clinical KB service detail page", () => { await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); // The compact dock reserve is 5.5rem (88px) plus any safe-area inset. await expect.poll(async () => readMobileComposerReservePx(mainContent)).toBeGreaterThanOrEqual(80); - await scrollPrimarySurface(page, "end"); - await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); - - await expect - .poll(async () => { - const geometry = await readPrimaryScrollGeometry(page); - return geometry.maxScrollTop - geometry.scrollTop; - }) - .toBeLessThanOrEqual(1); + // Document scrolling can change the settled range after the first endpoint + // jump (reserve/layout commit). Re-issue scroll-to-end while asserting so + // the position converges instead of polling a stale scrollTop (~67px left). + await expect(async () => { + await scrollPrimarySurface(page, "end"); + await expect(dock).not.toHaveAttribute("data-scroll-hidden", "true"); + const geometry = await readPrimaryScrollGeometry(page); + expect(geometry.owner).toBe("document"); + expect(geometry.maxScrollTop - geometry.scrollTop).toBeLessThanOrEqual(1); + }).toPass({ timeout: 15_000 }); const scrollGeometry = await readPrimaryScrollGeometry(page); const clearance = await footer.evaluate((element) => { diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index 9302508073..e2f1a79ab5 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -436,6 +436,20 @@ describe("runUniversalSearch (query intelligence & ranking)", () => { expect(medications?.items.some((item) => item.title.toLowerCase().includes("clozapine"))).toBe(true); }); + it("keeps shared clinical-search typo corrections for the medications domain", async () => { + const { runUniversalSearch } = await loadUniversalSearch(); + // "monitring" is corrected by analyzeClinicalQuery, not the catalog-local map. + // Medications must rank against the shared corrected base query, not the raw typo. + const response = await runUniversalSearch({ query: "monitring", limitPerDomain: 8, demo: true }); + expect(response.interpretation?.correctedQuery).toBe("monitoring"); + const medications = response.groups.find((group) => group.kind === "medications"); + expect((medications?.items.length ?? 0) > 0).toBe(true); + const monitoringHits = await runUniversalSearch({ query: "monitoring", limitPerDomain: 8, demo: true }); + const monitoringMeds = monitoringHits.groups.find((group) => group.kind === "medications"); + expect(medications?.items.length ?? 0).toBeGreaterThanOrEqual(1); + expect(monitoringMeds?.items.length ?? 0).toBeGreaterThanOrEqual(1); + }); + it("pins a confident best-bet as topHit and omits it when nothing is near-exact", async () => { const { runUniversalSearch } = await loadUniversalSearch(); const hit = await runUniversalSearch({ query: "acamprosate", limitPerDomain: 5, demo: true });