diff --git a/scripts/playwright-pr-shards.mjs b/scripts/playwright-pr-shards.mjs index 660e83375d..6a8d9d13d4 100644 --- a/scripts/playwright-pr-shards.mjs +++ b/scripts/playwright-pr-shards.mjs @@ -55,6 +55,8 @@ export const prUiSpecProfiles = Object.freeze([ { file: "tests/ui-visual-artifacts.spec.ts", shard: 3, fullSeconds: 2.7, criticalSeconds: 0 }, { file: "tests/ui-forms-section-nav.spec.ts", shard: 3, fullSeconds: 2.5, criticalSeconds: 0 }, { file: "tests/ui-therapy-nav-scroll.spec.ts", shard: 3, fullSeconds: 1.9, criticalSeconds: 0 }, + // Critical-only regression; use a conservative estimate until the next hosted timing sample. + { file: "tests/ui-phone-scroll-submitted-root.spec.ts", shard: 3, fullSeconds: 1.0, criticalSeconds: 1.0 }, // Skipped in the sampled runner because pdf.js could not raster there. { file: "tests/ui-document-canvas.spec.ts", shard: 3, fullSeconds: 0, criticalSeconds: 0 }, ]); diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 93d905ab19..888704d626 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -884,7 +884,7 @@ function GlobalStandaloneSearchShellBody({ } mobileBottomSearchAddonKind={differentialsCompareAddonActive ? "differentials-compare" : undefined} desktopSearchPlacement={desktopSearchPlacement === "hero" && isStandaloneModeHome ? "hero" : "default"} - showPhoneSuggestionTickerOnHome={isStandaloneModeHome || pathname === "/"} + showPhoneSuggestionTickerOnHome={isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)} searchComposerVisible={shouldShowSearchComposer} desktopHomeComposerSlotId={isStandaloneModeHome ? modeHomeDesktopComposerSlotId : undefined} desktopPageComposerSlotId={ diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index 4622c470e2..86c05f0fd6 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -185,10 +185,7 @@ function SmartRotatingHint({ const activeExample = examples[activeExampleIndex % examples.length]; useEffect(() => { - if (isTickerHeld) { - return; - } - + if (isTickerHeld) return; if (examples.length <= 1) return; const intervalId = window.setInterval(() => { setActiveExampleIndex((current) => (current + 1) % examples.length); @@ -200,12 +197,13 @@ function SmartRotatingHint({ setHeldTickerExample(activeExample); setIsTickerHeld(true); }, [activeExample]); - - const releaseTicker = useCallback(() => { - setIsTickerHeld(false); - }, []); - - const resolvedTickerExample = isTickerHeld ? (heldTickerExample ?? activeExample) : activeExample; + const releaseTicker = useCallback(() => setIsTickerHeld(false), []); + // A mode change can replace the examples while a pointer/focus hold is + // active. Never keep displaying or submit a held value that is no longer in + // the current mode's suggestion set. + const currentHeldTickerExample = + heldTickerExample && examples.includes(heldTickerExample) ? heldTickerExample : activeExample; + const resolvedTickerExample = isTickerHeld ? currentHeldTickerExample : activeExample; if (!activeExample) return null; diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index b06a1daa1c..061aeeab51 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -24,6 +24,66 @@ export function compactSearchText(value: string) { return value.replace(/\s+/g, ""); } +function typoDistanceLimit(term: string) { + // One edit recovers common clinical typos without allowing exact long drug + // names to cross-match distinct catalogue entries (for example fluoxetine + // and duloxetine, or prednisone and prednisolone). + if (term.length >= 5) return 1; + // Four-character clinical abbreviations (SSRI/SNRI, ADHD/ODD, etc.) are + // often one edit apart; require five characters before allowing a typo. + return 0; +} + +/** Bounded Damerau-Levenshtein distance for conservative catalogue typo recovery. */ +function boundedTypoDistance(left: string, right: string, limit: number) { + if (Math.abs(left.length - right.length) > limit) return limit + 1; + const previous = Array.from({ length: right.length + 1 }, (_, index) => index); + let previousPrevious: number[] | undefined; + + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + const current = [leftIndex]; + let rowMinimum = current[0]; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1; + let distance = Math.min( + current[rightIndex - 1] + 1, + previous[rightIndex] + 1, + previous[rightIndex - 1] + substitutionCost, + ); + if ( + previousPrevious && + leftIndex > 1 && + rightIndex > 1 && + left[leftIndex - 1] === right[rightIndex - 2] && + left[leftIndex - 2] === right[rightIndex - 1] + ) { + distance = Math.min(distance, previousPrevious[rightIndex - 2] + 1); + } + current[rightIndex] = distance; + rowMinimum = Math.min(rowMinimum, distance); + } + if (rowMinimum > limit) return limit + 1; + previousPrevious = previous.slice(); + previous.splice(0, previous.length, ...current); + } + return previous[right.length]; +} + +/** Number of query tokens with a conservative near-word match in normalized text. */ +function fuzzySearchTokens(query: string, text: string) { + const queryTokens = normalizeSearchText(query).split(/\s+/).filter(Boolean); + const words = Array.from(new Set(normalizeSearchText(text).split(/\s+/).filter(Boolean))); + return queryTokens.filter((term) => { + if (words.some((word) => word.includes(term))) return false; + const limit = typoDistanceLimit(term); + return limit > 0 && words.some((word) => boundedTypoDistance(term, word, limit) <= limit); + }); +} + +export function fuzzySearchTokenCount(query: string, text: string) { + return fuzzySearchTokens(query, text).length; +} + export type CatalogField = { // Wrapper-facing key used to build human-readable reasons (e.g. "title", "contact"). id: string; @@ -32,13 +92,15 @@ export type CatalogField = { }; export type CatalogMatchSignals = { - // Matched term count per field id (only fields with at least one match are present). + // Literal or conservative fuzzy matched term count per field id + // (only fields with at least one match are present). fields: Record; // Matched term count against the full-text haystack. content: number; // Matched term count for terms introduced by expandTokens (e.g. symptom // aliases) that were not part of the raw query. expanded: number; + fuzzy: number; compact: boolean; phrase: boolean; prefix: boolean; @@ -113,6 +175,14 @@ export function rankCatalogRecords( const text = options.fullText(record); const fields: Record = {}; let score = 0; + let fuzzy = 0; + const compact = + compactBonus > 0 && + compactQuery.length >= compactMinLength && + (compactSearchText(text).includes(compactQuery) || + (options.compactExtraText + ? compactSearchText(options.compactExtraText(record)).includes(compactQuery) + : false)); for (const field of options.fields) { const haystack = field.text(record); @@ -121,23 +191,32 @@ export function rankCatalogRecords( // align with a word boundary — substring hits ("renal" inside // "adrenaline") stay confined to the low-weight content haystack. const matched = terms.filter((term) => matchesTermAtWordBoundary(haystack, term)).length; - if (!matched) continue; - fields[field.id] = matched; - score += matched * field.weight; + if (matched) { + fields[field.id] = matched; + score += matched * field.weight; + } + + // A typo in a title/name/code field must retain that field's weight. + // Otherwise an intended record ties incidental full-text mentions and + // a limited universal-search result can omit the best match entirely. + const fuzzyFieldMatches = compact ? 0 : fuzzySearchTokenCount(normalizedQuery, haystack); + if (fuzzyFieldMatches) { + fields[field.id] = (fields[field.id] ?? 0) + fuzzyFieldMatches; + } + fuzzy += fuzzyFieldMatches; + score += fuzzyFieldMatches * field.weight; } const content = terms.filter((term) => text.includes(term)).length; score += content * contentWeight; const expanded = expandedTerms.filter((term) => text.includes(term)).length; + const fuzzyContentFallback = !compact && fuzzy === 0 ? fuzzySearchTokenCount(normalizedQuery, text) : 0; + fuzzy += fuzzyContentFallback; + // Broad full-text fuzzy evidence is only a fallback and stays weaker than + // literal content or a weighted field match. + score += fuzzyContentFallback * Math.max(1, contentWeight * 0.5); - const compact = - compactBonus > 0 && - compactQuery.length >= compactMinLength && - (compactSearchText(text).includes(compactQuery) || - (options.compactExtraText - ? compactSearchText(options.compactExtraText(record)).includes(compactQuery) - : false)); if (compact) score += compactBonus; const phrase = phraseBonus > 0 && text.includes(normalizedQuery); @@ -158,7 +237,17 @@ export function rankCatalogRecords( record, index, score, - signals: { fields, content, expanded, compact, phrase, prefix, exact, broad } satisfies CatalogMatchSignals, + signals: { + fields, + content, + expanded, + fuzzy, + compact, + phrase, + prefix, + exact, + broad, + } satisfies CatalogMatchSignals, }; }) .filter((match) => match.score > 0) diff --git a/tests/audit-navigation-auth-regressions.test.ts b/tests/audit-navigation-auth-regressions.test.ts index 9e80807fc7..15eac0c271 100644 --- a/tests/audit-navigation-auth-regressions.test.ts +++ b/tests/audit-navigation-auth-regressions.test.ts @@ -24,8 +24,18 @@ function sourceSegment(contents: string, startMarker: string, endMarker: string) const clinicalDashboardSource = source("src/components/ClinicalDashboard.tsx"); const masterSearchHeaderSource = source("src/components/clinical-dashboard/master-search-header.tsx"); const universalAlsoMatchesSource = source("src/components/clinical-dashboard/universal-search-also-matches.tsx"); +const universalCommandSurfaceSource = source("src/components/clinical-dashboard/universal-search-command-surface.tsx"); +const globalSearchShellSource = source("src/components/clinical-dashboard/global-search-shell.tsx"); describe("audit navigation and auth regressions", () => { + it("keeps the tappable phone suggestion ticker connected to standalone homes", () => { + expect(globalSearchShellSource).toContain('isStandaloneModeHome || (pathname === "/" && !hasSubmittedModeSearch)'); + expect(masterSearchHeaderSource).toContain("showPhoneSuggestionTicker={showPhoneSuggestionTickerOnHome}"); + expect(universalCommandSurfaceSource).toContain('data-testid="smart-search-phone-ticker"'); + expect(universalCommandSurfaceSource).toContain("onClick={() => onPickExample(resolvedTickerExample)}"); + expect(universalCommandSurfaceSource).toContain("examples.includes(heldTickerExample)"); + expect(universalCommandSurfaceSource).toContain("onQueryChange(example);"); + }); it("redirects exact legacy route handlers at request time while retaining useful query state", () => { const applications = redirectApplications( new NextRequest("https://clinical-kb.test/applications?q=acute+care&tag=one&tag=two"), diff --git a/tests/catalog-search-drug-name-regression.test.ts b/tests/catalog-search-drug-name-regression.test.ts new file mode 100644 index 0000000000..3620bf52bd --- /dev/null +++ b/tests/catalog-search-drug-name-regression.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search"; + +type Drug = { name: string }; + +const drugs: Drug[] = [ + { name: "Fluoxetine" }, + { name: "Duloxetine" }, + { name: "Prednisone" }, + { name: "Prednisolone" }, +]; + +function search(query: string) { + return rankCatalogRecords(drugs, query, { + fields: [{ id: "name", weight: 8, text: (drug) => normalizeSearchText(drug.name) }], + fullText: (drug) => normalizeSearchText(drug.name), + exactValues: (drug) => [normalizeSearchText(drug.name)], + exactBonus: 10, + }); +} + +describe("catalogue drug-name typo recovery", () => { + it("does not fuzzy-match a distinct long medication name beside an exact match", () => { + expect(search("fluoxetine").map((match) => match.record.name)).toEqual(["Fluoxetine"]); + expect(search("prednisone").map((match) => match.record.name)).toEqual(["Prednisone"]); + }); + + it("still recovers a single-edit typo in a long medication name", () => { + expect(search("fluoxetne")[0]?.record.name).toBe("Fluoxetine"); + expect(search("prednisne")[0]?.record.name).toBe("Prednisone"); + }); +}); diff --git a/tests/catalog-search.test.ts b/tests/catalog-search.test.ts index c788eff509..897bb2318b 100644 --- a/tests/catalog-search.test.ts +++ b/tests/catalog-search.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { compactSearchText, normalizeSearchText, rankCatalogRecords } from "../src/lib/catalog-search"; +import { + compactSearchText, + fuzzySearchTokenCount, + normalizeSearchText, + rankCatalogRecords, +} from "../src/lib/catalog-search"; type Item = { title: string; slug: string; tags: string[]; body: string }; @@ -56,6 +61,62 @@ describe("rankCatalogRecords", () => { expect(results.some((match) => match.record.slug === "lithium-levels")).toBe(false); }); + it("finds close catalogue words after an insertion, omission, or transposition", () => { + expect(rank("clozpaine")[0]?.record.slug).toBe("clozapine-monitoring"); + expect(rank("lithum")[0]?.record.slug).toBe("lithium-levels"); + expect(fuzzySearchTokenCount("monitroing", "Clozapine monitoring guidance")).toBe(1); + }); + + it("weights and reports a fuzzy title above an incidental full-text mention", () => { + const records = [ + { title: "Schizophrenia", body: "Core diagnostic record" }, + { title: "Other condition", body: "Consider schizophrenia in the differential" }, + ]; + const results = rankCatalogRecords(records, "schizophrnia", { + fields: [{ id: "title", weight: 8, text: (record) => normalizeSearchText(record.title) }], + fullText: (record) => normalizeSearchText(`${record.title} ${record.body}`), + }); + + expect(results[0]?.record.title).toBe("Schizophrenia"); + expect(results[0]?.signals.fuzzy).toBe(1); + expect(results[0]?.signals.fields.title).toBe(1); + }); + + it("does not fuzz short clinical abbreviations or unrelated words", () => { + expect(fuzzySearchTokenCount("GAD", "Major depressive disorder")).toBe(0); + expect(fuzzySearchTokenCount("SSRI", "SNRI")).toBe(0); + expect(rank("transport").some((match) => match.record.slug === "clozapine-monitoring")).toBe(false); + }); + + it("never cross-matches a distinct drug two edits away, even with both records present", () => { + // Ledger #310: with a >=8-char / 2-edit tier, fluoxetine matched duloxetine (substitute + // f->d + transpose lu->ul counts as 2 Damerau edits) and prednisone matched prednisolone. + // The 1-edit cap must exclude the wrong drug while the exact drug still ranks, and while + // genuine single-edit typo recovery keeps working. + const drugs = [ + { title: "Fluoxetine", body: "SSRI dosing" }, + { title: "Duloxetine", body: "SNRI dosing" }, + { title: "Prednisone", body: "Corticosteroid taper" }, + { title: "Prednisolone", body: "Corticosteroid taper" }, + { title: "Sertraline", body: "SSRI dosing" }, + ]; + const rankDrugs = (query: string) => + rankCatalogRecords(drugs, query, { + fields: [{ id: "title", weight: 8, text: (record) => normalizeSearchText(record.title) }], + fullText: (record) => normalizeSearchText(record.title), + }); + + const fluoxetine = rankDrugs("fluoxetine"); + expect(fluoxetine[0]?.record.title).toBe("Fluoxetine"); + expect(fluoxetine.some((match) => match.record.title === "Duloxetine")).toBe(false); + + const prednisone = rankDrugs("prednisone"); + expect(prednisone[0]?.record.title).toBe("Prednisone"); + expect(prednisone.some((match) => match.record.title === "Prednisolone")).toBe(false); + + expect(rankDrugs("setraline")[0]?.record.title).toBe("Sertraline"); + }); + it("applies the whole-phrase bonus on top of term matches", () => { const [top] = rank("clozapine monitoring"); // 2 title terms (12) + 2 content terms (4) + phrase (4). diff --git a/tests/dsm.test.ts b/tests/dsm.test.ts index 5f94a6ad6b..f6d975366e 100644 --- a/tests/dsm.test.ts +++ b/tests/dsm.test.ts @@ -44,6 +44,10 @@ describe("DSM clinical catalogue", () => { ).toBe(true); }); + it("keeps a typo-corrected diagnosis title ahead of incidental clinical mentions", () => { + expect(rankDsmDiagnoses("schizophrnia", 5).some((match) => match.diagnosis.slug === "schizophrenia")).toBe(true); + }); + it("links named differential considerations back to catalogue records", () => { const diagnosis = getDsmDiagnosis("major-depressive-disorder"); const bipolar = diagnosis?.differentials.find((item) => item.startsWith("Bipolar I or II")); diff --git a/tests/ui-phone-scroll-submitted-root.spec.ts b/tests/ui-phone-scroll-submitted-root.spec.ts new file mode 100644 index 0000000000..52658e9198 --- /dev/null +++ b/tests/ui-phone-scroll-submitted-root.spec.ts @@ -0,0 +1,59 @@ +import { expect, test, type Page } from "playwright/test"; + +const readySetupChecks = [ + { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, + { id: "project", label: "Clinical KB Database target", status: "ready", detail: "Test Supabase project ready." }, + { id: "schema", label: "supabase/schema.sql applied", status: "ready", detail: "Test schema ready." }, + { id: "search", label: "Search RPC and vector indexes", status: "ready", detail: "Test search schema ready." }, + { id: "openai", label: "OpenAI API key available", status: "ready", detail: "Test OpenAI ready." }, + { id: "worker", label: "npm run worker running", status: "unknown", detail: "Worker not required for UI smoke." }, +]; + +async function mockDemoDashboard(page: Page) { + await page.route("**/api/setup-status**", async (route) => { + await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); + }); + await page.route(/\/api\/local-project-id$/, async (route) => { + await route.fulfill({ + json: { + appName: "Clinical Guide", + projectId: "test-project", + identityPath: "/api/local-project-id", + localServer: { + currentUrl: "http://localhost:4298", + currentPort: 4298, + projectPortStart: 4298, + projectPortEnd: 53210, + safeLocalOrigin: true, + requestOrigin: null, + requestReferer: null, + unsafeLocalCaller: null, + }, + }, + }); + }); + await page.route(/\/api\/documents(?:\?.*)?$/, async (route) => { + await route.fulfill({ + json: { + documents: [], + demoMode: true, + pagination: { limit: 150, offset: 0, total: 0, nextOffset: 0, hasMore: false }, + }, + }); + }); +} + +test("submitted root search keeps its query and hides the phone suggestion ticker @critical", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await mockDemoDashboard(page); + await page.goto("/?mode=answer&q=lithium&run=1", { waitUntil: "domcontentloaded" }); + + await expect(async () => { + const header = page.locator("header#search"); + await expect(header).toHaveCount(1); + await expect(header).toBeVisible(); + }).toPass({ timeout: 30_000 }); + + await expect(page.locator('[data-testid="global-search-input"]:visible').first()).toHaveValue("lithium"); + await expect(page.getByTestId("smart-search-phone-ticker")).toBeHidden(); +});