Universal search: fix core RAG search defects and federate medications/services/forms/differentials/tools into one cross-entity search - #325
Conversation
… follow-ups Phase 0 of the search hardening + universal search workstream: - weakRetrievalTopScoreThreshold (0.48) moves from duplicated magic literals in /api/search into rag-routing.ts beside the routing thresholds, with an ordering test (0.32 < 0.48 < 0.64 < 0.76). - search-rag-master-context.md no longer claims a 12000ms answer budget; the 30000ms default in env.ts is a documented deliberate product decision. - Six follow-ups filed in rag-hybrid-findings-and-todo.md (alias promotion, index-unit HNSW, demo-fallback masking, governance-regression guard, synthetic-similarity recalibration, registry-to-corpus embedding). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJ
… weak-match recall Phase 1 of the search hardening workstream — four independently revertable fixes: 1.1 Memoize the LLM query-classifier verdict (finding #11 interim fix per docs/process-hardening.md): successful verdicts (accepted AND rejected) are cached 15 min with in-flight dedup, so the unsupported short-circuit can no longer flip run-to-run and intermittently return 0 results for valid queries like "bipolar disorder". Transport errors stay retryable. 1.2 Complete the saturation tiebreak: rankClinicalResults now emits its pre-clamp boost sum as score_explanation.preClampFinalScore, and selectRetrievalEvidence uses it after score/lexical/rerank ties — fully tied 1.0-saturated candidates no longer order by arbitrary chunk id. Tie-only by construction; the clamped primary score is untouched. 1.3 OR-relaxation for weak-but-nonzero strict text matches (P8b extension): when strict-AND retrieval returns a sparse set (<3) or negligible top text_rank (<0.05), append OR-relaxed recall BEHIND the strict matches (append-only merge; strict results keep precedence). Kill switch: RAG_TEXT_WEAK_OR_RELAXATION=false. Telemetry: text_or_relaxation_used. 1.4 RC9 observability: fabricated (non-cosine) similarities from the document-lookup, memory-card, and table-facts paths are tagged similarity_origin="synthetic_text" and counted in telemetry (synthetic_similarity_count) ahead of any gate recalibration. Eval-gate note: eval:retrieval:quality (23/23) and eval:quality --rag-only need live keys and must be run before merge per the governance preflight; 1.3 ships behind the env kill switch for exactly that reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJ
Phase 2 of the search hardening + universal search workstream:
- New src/lib/catalog-search.ts: one canonical normalizeSearchText (the
medications superset — NFKD + diacritic strip, keeps + . / - for dose
strings) replacing four divergent per-domain normalizers, and a generic
rankCatalogRecords weighted ranker returning match signals so each domain
wrapper keeps its historical reason labels and match shapes.
- medications/services/forms rankers become thin wrappers with their exact
historical weights; tests/{medications,services,forms}.test.ts pass
unmodified as parity oracles.
- Differentials get real relevance ranking (rankDifferentialRecords) with
alias expansion, replacing the unranked includes() OR-filter; empty-query
full-catalogue browse preserved.
- Forms stop masquerading as documents: app-modes gains a "forms"
search/result kind, removing the ClinicalDashboard special case; forms no
longer qualify as a source-library shortcut (scope-tag clicks fall back to
documents mode). Prescribing deliberately keeps kind:"documents" — it
searches the corpus for dosing guidance (rationale recorded inline).
- Tools dataset deduplicated into src/lib/tools-catalog.ts (icon-free so
server code can use it) + rankToolRecords; the launcher derives its apps
from it and the mockup fixtures derive identity fields from it.
- Amend Phase-1 OR-relaxation heuristic: a sparse strict set anchored by a
strong lexical hit (top text_rank >= 0.3) no longer augments — exact
single-match lookups stay one-RPC retrievals (caught by the answer
coalescing test asserting a single text RPC).
New tests: catalog-search, tools-catalog; extended differentials and
app-modes contracts; sitemap regenerated (mode kind is embedded there).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJ… catalogs Phase 3 of the universal search workstream: - New src/lib/universal-search.ts: runUniversalSearch() fans out in parallel (Promise.allSettled) to the document retrieval pipeline (searchChunksWithTelemetry + fetchRelatedDocuments) and the shared Phase-2 registry rankers (medications, services, forms, differentials, tools). Per-domain timeouts (2.5s registry / 6s documents); a failed domain yields an empty errored group so one broken adapter never blanks the response. No cross-domain score normalization — registry integer weights and [0,1] document scores are incommensurable, so ordering is by fixed group order, ranked within each group. - New GET /api/search/universal (typeahead-friendly): q (min 2), limit (max 10), domains CSV. Access ladder mirrors /api/registry/records: demo/local -> fixtures, public -> public catalogues, owner -> seeded records, with the shared "registry" rate-limit bucket and Cache-Control: private, no-store. - The owner fetch + lazy-seed blocks are extracted from /api/medications and /api/registry/records into fetchOwnerMedicationRowsWithSeed / fetchOwnerRegistryRowsWithSeed so the routes and universal search share one code path. - Tests: universal-search lib (fixed group order, per-domain fixtures with working hrefs, domain filtering, error isolation, demo document search) and route (demoMode payload, min-length validation, unknown-domain CSV). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJ
…d surface
Phase 4 of the universal search workstream:
- New use-universal-search hook: 250ms-debounced GET /api/search/universal,
enabled only while the command dropdown is open with a 2+ char query,
excluding the active mode's own domain (its results already come from the
mode search). Race handling mirrors the dashboard's monotonic request
token, and visible groups are derived from the fetched query so a stale
result set is never rendered against a newer query.
- UniversalSearchCommandSurface renders the grouped cross-entity results
("Medications · 3", "Forms · 1", ...) between suggestions and mode
actions; selecting an item navigates straight to the record, and each
group ends with a "View all in {mode}" row reusing the existing
cross-mode plumbing. Keyboard navigation and aria come free from the
existing flatItems listbox. Enter with nothing highlighted still runs the
mode-scoped search; errored/empty domains render nothing; a subtle
spinner row shows while the federated request is pending.
- use-medication-catalog now sends the auth session header (matching
use-registry-records) — an authenticated owner was previously served the
public fixture catalogue silently.
- New tests/ui-universal-search.spec.ts (registered in the Playwright
testMatch): grouped section renders while typing, selection navigates to
/medications/{slug}, Enter preserves mode search. Verified green on
Chromium against a live demo-mode dev server, along with the full
ui-tools suites (40/40) after the Phase-2 tools dedupe.
- Verification state and remaining eval debt recorded in
docs/process-hardening.md (golden retrieval eval requires live keys and
must run before merge per the standing gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJnpm run format:check is part of the PR verification contract; nine files from the preceding phases needed --write. No behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJ
This pull request has been ignored for the connected project Preview Branches by Supabase. |
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Auth change stale typeahead
- When authorizationHeader changes, the hook now clears cached result groups so fresh becomes false and the dropdown shows loading until the auth-scoped refetch completes.
You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 716b596. Configure here.
| if (!active) return { groups: [], loading: false, query: "" }; | ||
| const fresh = result.query === trimmedQuery; | ||
| return { groups: fresh ? result.groups : [], loading: !fresh, query: result.query }; |
There was a problem hiding this comment.
Auth change stale typeahead
Medium Severity
When authorizationHeader changes (sign-in, sign-out, or token refresh), the hook refetches but keeps the prior result state. While result.query still equals the current input, fresh stays true and loading false, so the dropdown can keep showing the previous access tier’s universal results (e.g. public fixtures) with no spinner until the debounced request finishes.
Reviewed by Cursor Bugbot for commit 716b596. Configure here.
…ankers Main's #325 added its own snapshot-only rankDifferentialRecords(query) on the shared rankCatalogRecords primitive while this branch shipped a records-parameter variant that the /api/differentials route uses to rank live owner rows. Resolution keeps one ranker built on the shared primitive with the records-first signature (matching the medication and service rankers): title x8 + hinge/safety x3 fields, alias expansion surfaced as a "symptom alias" reason via a new `expanded` signal, exact/compact/phrase bonuses, and urgency as a tie-break only. The primitive also gains an optional prefixValues/prefixBonus so the medication ranker keeps this branch's name-prefix boost, and the universal-search differentials domain passes the snapshot explicitly. Test suites from both sides are retained on the unified signature. Verified post-merge: verify:cheap (1152 tests), format:check, chromium ui-tools 36/36 and ui-universal-search 3/3 against the live dev server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UuyyMMegXxTeEJsyR741t


Summary
src/lib/rag.ts).rankClinicalResultsnow emits its pre-clamp boost sum (score_explanation.preClampFinalScore) andselectRetrievalEvidenceuses it after score/lexical/rerank ties, so fully tied saturated candidates no longer order by arbitrary chunk id. Tie-only by construction; the clamped primary score is untouched.text_rank) now appends OR-relaxed recall behind the strict matches (append-only merge). A sparse set anchored by a strong lexical hit (top rank ≥ 0.3) does not relax, so exact lookups stay one-RPC retrievals. Kill switch:RAG_TEXT_WEAK_OR_RELAXATION=false.similarity_origin:"synthetic_text"and counted in telemetry ahead of any gate recalibration.weakRetrievalTopScoreThresholdinrag-routing.tswith an ordering test; stale 12000ms answer-budget docs reconciled with the deliberate 30000ms default; six follow-ups filed indocs/rag-hybrid-findings-and-todo.md.normalizeSearchTextand a genericrankCatalogRecordsreplace the four divergent per-domain rankers. Medications/services/forms wrappers keep their exact historical weights, reason strings, and match shapes (tests/{medications,services,forms}.test.tspass unmodified as parity oracles). Differentials get real relevance ranking with alias expansion (previously an unrankedincludes()filter). The duplicated Tools dataset is consolidated intosrc/lib/tools-catalog.ts(icon-free so server code can use it). Forms stop masquerading askind:"documents"(new honestformskind removes the dashboard special-casing); prescribing deliberately keepskind:"documents"with the rationale recorded inline.GET /api/search/universalfederates one parallel in-process fan-out (Promise.allSettled, per-domain timeouts, error isolation) across document retrieval (searchChunksWithTelemetry+fetchRelatedDocuments) and the shared registry rankers. Access ladder mirrors/api/registry/records(demo → fixtures, public → public catalogues, owner → lazily-seeded records, shared rate-limit bucket). No cross-domain score normalization — registry integer weights and [0,1] document scores are incommensurable, so ordering is fixed group order, ranked within group.UniversalSearchCommandSurfacegains grouped cross-entity results while typing (debounced hook with a monotonic race token, excluding the active mode's own domain). Selecting an item navigates straight to the record; each group ends with a "View all in {mode}" row reusing the existing cross-mode plumbing; Enter with nothing highlighted still runs the mode-scoped search.use-medication-catalogalso now sends the auth header (owners were silently served fixture data).Verification
npm run verify:cheap— green (lint, typecheck, 1139 vitest, runtime check, sitemap check)npm run verify:uiequivalent — Chromium against a live demo-mode dev server: newui-universal-search.spec.ts(3/3),ui-tools+ui-tools-task-directory(40/40),ui-smoke+ui-overlap(59/59); plus live curl of/api/search/universal(grouped payload, domain CSV filter, 400 on short query)npm run verify:release— not run (release gate; needs live keys/Deno)npm run format:checknpm run eval:retrieval:quality(must stay 23/23) — could NOT run in the authoring environment (no live Supabase/OpenAI keys). Must be run before merge. Pay attention to the weak-match OR-augmentation (settingRAG_TEXT_WEAK_OR_RELAXATION=falserestores relax-on-empty exactly) and the retrieval-selection tiebreak (tie-only). The classifier memo also needseval:quality -- --rag-onlyto confirmunsupported_correct_ratestays 1.0.npm run eval:rag -- --limit 15+npm run eval:quality -- --rag-only— answer generation itself is untouched, but the classifier memo affects routing inputs; run alongside the retrieval eval before merge.npm run check:production-readinessbehaviour unchanged for env/deployment; search-behaviour verification state recorded indocs/process-hardening.mdnpm run check:deployment-readiness— no deployment/startup behaviour changedClinical Governance Preflight
Clinical KB Database(sjrfecxgysukkwxsowpy) — no env/project changesisDemoMode()ladder and flagsdemoMode:true)Notes
docs/process-hardening.md("Retrieval changes must pass the golden eval before merge"). Every retrieval change here was engineered tie-only/append-only, and the riskiest one has an env kill switch, specifically to make that gate easy to satisfy or roll back.docs/rag-hybrid-findings-and-todo.md) include the optional Phase-5 registry-to-corpus embedding so Answer mode could eventually cite medications/services/forms — deliberately excluded here to keep the eval-gated pgvector corpus untouched.ui-universal-searchspec mocks the universal endpoint; an end-to-end owner-auth spec needs theE2E_USER_*Playwright project.🤖 Generated with Claude Code
https://claude.ai/code/session_011QiyE8jMm7VnrtknHF6jnJ
Generated by Claude Code
Note
High Risk
Touches core RAG text retrieval, classifier routing memoization, and evidence selection tiebreaks—areas that previously regressed golden evals without unit-test coverage; merge still depends on live
eval:retrieval:quality/ RAG quality gates.Overview
Delivers universal search end-to-end:
GET /api/search/universalruns a parallel, per-domain federated search (documents via RAG + medications/services/forms/differentials/tools via shared rankers), with demo/public/owner access aligned to registry routes and isolated failures per domain. The command surface adds debounced “Across Clinical KB” typeahead (excluding the active mode’s domain), direct navigation to records, and “view all in {mode}” rows; medication catalog fetches now send auth so owners get seeded data.Catalog layer:
catalog-search+tools-catalogreplace duplicated normalizers/rankers and the launcher/tools fixture split. Forms use an honestformssearch kind (notdocuments); dashboard/nav/composer paths are updated accordingly.RAG retrieval changes (each with tests and/or kill switches where noted): LLM query-classifier verdict memoization (15m TTL, no error caching) for stable routing; pre-clamp score emitted and used as a tiebreak when clamped scores saturate at 1.0; weak strict-AND text matches can append OR-relaxed recall behind strict hits (
RAG_TEXT_WEAK_OR_RELAXATION=falserestores relax-on-empty);similarity_origin: synthetic_textand telemetry for non-cosine similarities; sharedweakRetrievalTopScoreThresholdfor weak-search logging. Registry/medication APIs use shared fetch-with-seed helpers for universal search parity.Docs/env:
RAG_TEXT_WEAK_OR_RELAXATION, answer timeout docs corrected to 30s default; process-hardening notes eval debt before merge.Reviewed by Cursor Bugbot for commit 716b596. Configure here.