From 0399ec7c3789cb892e0314454f971ba554839eab Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:56:30 +0800 Subject: [PATCH] feat: add presentations domain to universal search with bidirectional differential linking Presentations (umbrella work-up pages) become a seventh universal-search domain with their own result group, and the two differential rankers gain low-weight cross-entity lanes: diagnoses index their parent presentations' titles and presentations index their candidate diagnoses' titles, so a symptom query surfaces the presentation plus its differentials and a diagnosis query surfaces the work-up that contains it. Domain type/order move to a leaf module so the client hook can value-import the list without pulling the server search graph into the browser bundle. Differentials-mode chip counts sum both domains; canonical order keeps exact diagnosis titles ahead of the umbrella for Best match. Co-Authored-By: Claude Fable 5 --- .../universal-search-command-surface.tsx | 38 ++++--- .../use-universal-search.ts | 8 +- src/lib/differentials.ts | 57 +++++++++- src/lib/universal-search-domains.ts | 23 ++++ src/lib/universal-search.ts | 52 ++++++--- tests/differentials.test.ts | 31 +++++ tests/ui-universal-search.spec.ts | 32 ++++++ tests/universal-search.test.ts | 106 +++++++++++++++++- 8 files changed, 313 insertions(+), 34 deletions(-) create mode 100644 src/lib/universal-search-domains.ts diff --git a/src/components/clinical-dashboard/universal-search-command-surface.tsx b/src/components/clinical-dashboard/universal-search-command-surface.tsx index efbb200b6..abe40453a 100644 --- a/src/components/clinical-dashboard/universal-search-command-surface.tsx +++ b/src/components/clinical-dashboard/universal-search-command-surface.tsx @@ -22,15 +22,17 @@ import { } from "@/lib/search-command-surface"; import type { UniversalSearchDomain } from "@/lib/universal-search"; -// Reverse of modeIdByDomain for chip counts: the domain whose live result total a -// cross-mode chip should show. Answer/favourites chips have no countable domain. -const domainByTargetMode: Partial> = { - documents: "documents", - prescribing: "medications", - services: "services", - forms: "forms", - differentials: "differentials", - tools: "tools", +// Reverse of modeIdByDomain for chip counts: the domains whose live result totals a +// cross-mode chip should sum. Answer/favourites chips have no countable domain; the +// differentials chip counts both of its domains because the mode home search composes +// presentations and diagnoses into one result list. +const domainsByTargetMode: Partial> = { + documents: ["documents"], + prescribing: ["medications"], + services: ["services"], + forms: ["forms"], + differentials: ["differentials", "presentations"], + tools: ["tools"], }; const modeIdByDomain: Record = { @@ -39,6 +41,9 @@ const modeIdByDomain: Record = { services: "services", forms: "forms", differentials: "differentials", + // Presentations are the differentials mode's umbrella pages — no app mode of their own, + // so the group borrows the differentials icon and "View all in Differentials" target. + presentations: "differentials", tools: "tools", }; @@ -48,6 +53,7 @@ const domainHeadings: Record = { services: "Services", forms: "Forms", differentials: "Differentials", + presentations: "Presentations", tools: "Tools", }; @@ -640,11 +646,15 @@ export function UniversalSearchCommandSurface({ const TargetIcon = appModeIcons[target]; // Live count from the universal typeahead response ("Forms (2)") — only shown when // fresh results for this exact query exist, so the chip never shows a stale number. - const targetDomain = domainByTargetMode[target]; - const targetCount = - targetDomain && universalQuery === trimmedQuery - ? universalGroups.find((group) => group.kind === targetDomain)?.total - : undefined; + // A mode spanning several domains (differentials) sums its present groups' totals. + const targetDomains = domainsByTargetMode[target]; + const countableGroups = + targetDomains && universalQuery === trimmedQuery + ? universalGroups.filter((group) => targetDomains.includes(group.kind)) + : []; + const targetCount = countableGroups.length + ? countableGroups.reduce((sum, group) => sum + group.total, 0) + : undefined; return { id: nextId(), label: targetMode.label, diff --git a/src/components/clinical-dashboard/use-universal-search.ts b/src/components/clinical-dashboard/use-universal-search.ts index 21baef25d..729f98763 100644 --- a/src/components/clinical-dashboard/use-universal-search.ts +++ b/src/components/clinical-dashboard/use-universal-search.ts @@ -4,11 +4,13 @@ import { useEffect, useRef, useState } from "react"; import type { UniversalSearchAnswerAction, - UniversalSearchDomain, UniversalSearchGroup, UniversalSearchInterpretation, UniversalSearchTopHit, } from "@/lib/universal-search"; +// Value import from the leaf module only: universal-search.ts itself is server-only +// (snapshot catalogues, rag, supabase) and must never enter the client bundle. +import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; import { useAuthSession } from "@/lib/supabase/client"; export type UniversalSearchState = { @@ -128,9 +130,7 @@ export function useUniversalSearch(args: { // is not enough — abort frees the server/DB work too. const controller = new AbortController(); const timer = window.setTimeout(() => { - const domains = (["documents", "medications", "services", "forms", "differentials", "tools"] as const).filter( - (domain) => domain !== excludeDomain, - ); + const domains = universalSearchDomains.filter((domain) => domain !== excludeDomain); const params = new URLSearchParams({ q: trimmedQuery, limit: String(limitPerDomain), diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index e215965c8..31cd59e89 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -120,6 +120,47 @@ function expandQueryTerms(terms: string[]) { return [...expanded]; } +// Cross-entity link indexes: presentations and diagnoses are one logical catalogue joined +// by workflow.candidates[].slug, so each ranker also indexes the other kind's titles at low +// weight. Both caches are built lazily from the static snapshot (no invalidation needed). + +let diagnosisTitleBySlugCache: Map | null = null; + +function diagnosisTitleBySlug(): Map { + diagnosisTitleBySlugCache ??= new Map(differentialRecords.map((record) => [record.slug, record.title])); + return diagnosisTitleBySlugCache; +} + +let presentationTitleTextBySlugCache: Map | null = null; + +/** Reverse index text: normalized titles of the snapshot presentations that list this + * diagnosis as a candidate, so a presentation-shaped query ("acute confusion") surfaces + * the differentials it works up. */ +function presentationTitleTextForDiagnosis(slug: string): string { + if (!presentationTitleTextBySlugCache) { + presentationTitleTextBySlugCache = new Map(); + for (const workflow of differentialPresentations()) { + const text = normalizeSearchText(`${workflow.title} ${workflow.id}`); + for (const candidate of workflow.candidates) { + const existing = presentationTitleTextBySlugCache.get(candidate.slug); + presentationTitleTextBySlugCache.set(candidate.slug, existing ? `${existing} ${text}` : text); + } + } + } + return presentationTitleTextBySlugCache.get(slug) ?? ""; +} + +/** Forward index text: the candidate differentials' titles (falling back to de-hyphenated + * slugs for candidates outside the snapshot, e.g. owner-edited rows), so a diagnosis-shaped + * query ("wernicke") surfaces the presentations that work it up. Computed from the passed + * workflow so owner rows ranked via the differentials API get the same treatment. */ +function candidateTitlesText(workflow: DifferentialPresentationWorkflow): string { + const titles = diagnosisTitleBySlug(); + return normalizeSearchText( + workflow.candidates.map((candidate) => titles.get(candidate.slug) ?? candidate.slug.replace(/-/g, " ")).join(" "), + ); +} + const differentialStatusRank: Record = { emergent: 0, urgent: 1, @@ -183,6 +224,11 @@ export function rankDifferentialRecords( fields: [ { id: "title", weight: 8, text: (record) => normalizeSearchText(`${record.title} ${record.slug}`) }, { id: "hinge", weight: 3, text: diagnosisHingeText }, + // Cross-entity lane (kept out of fullText so it never earns the phrase/content + // double-count): weight sits well below title/hinge, so a presentation-shaped query + // surfaces the candidates without ever outranking a direct match; equal-score + // candidates fall to the emergent-first tie-break below. + { id: "presentations", weight: 2, text: (record) => presentationTitleTextForDiagnosis(record.slug) }, ], fullText: diagnosisFullText, contentWeight: 2, @@ -203,6 +249,7 @@ export function rankDifferentialRecords( signals.fields.title ? "title" : "", signals.exact || signals.compact ? "exact name" : "", signals.fields.hinge ? "clinical hinge/safety" : "", + signals.fields.presentations ? "presentation link" : "", signals.content ? "content" : "", signals.expanded ? "symptom alias" : "", ].filter(Boolean), @@ -234,11 +281,18 @@ export function rankPresentationWorkflows( workflows: DifferentialPresentationWorkflow[], query: string, limit = 20, + // Low-weight synonym/acronym/alias terms (see rankDifferentialRecords) composed onto the + // catalogue's own symptom-alias expansion for the shared ranker's expanded lane. + expansions: string[] = [], ): DifferentialPresentationMatch[] { return rankCatalogRecords(workflows, query, { fields: [ { id: "title", weight: 8, text: (workflow) => normalizeSearchText(`${workflow.title} ${workflow.id}`) }, { id: "safety", weight: 4, text: presentationSafetyText }, + // Cross-entity lane (see rankDifferentialRecords' "presentations" field): the candidate + // differentials' titles, so a diagnosis-shaped query surfaces the presentations that + // work it up without outranking the diagnosis's own record. + { id: "candidates", weight: 2, text: candidateTitlesText }, ], fullText: presentationFullText, contentWeight: 2, @@ -247,7 +301,7 @@ export function rankPresentationWorkflows( phraseBonus: 4, exactValues: (workflow) => [normalizeSearchText(workflow.title), normalizeSearchText(workflow.id)], exactBonus: 10, - expandTokens: expandQueryTerms, + expandTokens: expansions.length ? (terms) => [...expandQueryTerms(terms), ...expansions] : expandQueryTerms, limit, tieBreak: (left, right) => differentialStatusRank[left.status] - differentialStatusRank[right.status] || @@ -259,6 +313,7 @@ export function rankPresentationWorkflows( signals.fields.title ? "title" : "", signals.exact || signals.compact ? "exact name" : "", signals.fields.safety ? "safety focus" : "", + signals.fields.candidates ? "candidate differential" : "", signals.content ? "content" : "", signals.expanded ? "symptom alias" : "", ].filter(Boolean), diff --git a/src/lib/universal-search-domains.ts b/src/lib/universal-search-domains.ts new file mode 100644 index 000000000..df0a130ef --- /dev/null +++ b/src/lib/universal-search-domains.ts @@ -0,0 +1,23 @@ +// Leaf module: the universal-search domain registry, split out so client code (the +// typeahead hook) can value-import the domain list without pulling the server-only +// search graph in universal-search.ts (snapshot catalogues, rag, supabase) into the +// browser bundle. universal-search.ts re-exports both names for server consumers. + +export type UniversalSearchDomain = + "documents" | "medications" | "services" | "forms" | "differentials" | "presentations" | "tools"; + +// Canonical order: the default group order in responses AND the topHit tiebreak when +// several domains hold a confident (whole-phrase title) match. "presentations" sits +// after "differentials" so an exact diagnosis-title hit (e.g. "substance intoxication", +// which is both a diagnosis and an umbrella presentation title) wins Best match over +// the umbrella, while symptom phrases that only match a presentation title still +// promote the Presentations group to lead via confident-domain ordering. +export const universalSearchDomains: UniversalSearchDomain[] = [ + "documents", + "medications", + "services", + "forms", + "differentials", + "presentations", + "tools", +]; diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index 2a598efc6..566bbd2e1 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -3,7 +3,12 @@ import { analyzeClinicalQuery } from "@/lib/clinical-search"; import { demoSearch } from "@/lib/demo-data"; import { fetchRelatedDocuments } from "@/lib/document-enrichment"; import { documentsSearchHref } from "@/lib/document-flow-routes"; -import { differentialRecords, rankDifferentialRecords } from "@/lib/differentials"; +import { + differentialPresentations, + differentialRecords, + rankDifferentialRecords, + rankPresentationWorkflows, +} from "@/lib/differentials"; import { formRecords, rankFormRecords, type FormRecord } from "@/lib/forms"; import { rowToMedicationRecord } from "@/lib/medication-records"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; @@ -15,6 +20,7 @@ import { fetchOwnerRegistryRowsWithSeed } from "@/lib/registry-seed"; import { rankServiceRecords, serviceRecords, type ServiceRecord } from "@/lib/services"; import { rankToolRecords } from "@/lib/tools-catalog"; import type { ClinicalQueryAnalysis, SearchResult } from "@/lib/types"; +import { universalSearchDomains, type UniversalSearchDomain } from "@/lib/universal-search-domains"; // Server-side federated cross-entity search: one parallel in-process fan-out to the document // retrieval pipeline plus the shared registry rankers (medications, services, forms, @@ -25,16 +31,10 @@ import type { ClinicalQueryAnalysis, SearchResult } from "@/lib/types"; type AdminClient = ReturnType; -export type UniversalSearchDomain = "documents" | "medications" | "services" | "forms" | "differentials" | "tools"; - -export const universalSearchDomains: UniversalSearchDomain[] = [ - "documents", - "medications", - "services", - "forms", - "differentials", - "tools", -]; +// Domain type + canonical order live in the universal-search-domains leaf module (client +// code value-imports the list from there); re-exported here for server consumers. +export { universalSearchDomains }; +export type { UniversalSearchDomain }; export type UniversalSearchItem = { id: string; @@ -215,6 +215,29 @@ async function searchDifferentialsDomain(args: ResolvedSearchArgs): Promise { + // Presentations share the differentials snapshot (owner edits surface only on detail pages + // today), so demo and live share the in-bundle catalogue. The ranker's cross-entity lane + // also matches candidate differential titles, so a diagnosis-shaped query surfaces the + // umbrella work-up alongside the diagnosis itself. + return rankPresentationWorkflows( + differentialPresentations(), + args.baseQuery, + args.limitPerDomain, + args.expansions, + ).map((match) => ({ + id: match.workflow.id, + kind: "presentations", + title: match.workflow.title, + subtitle: match.workflow.subtitle || undefined, + href: `/differentials/presentations/${match.workflow.id}`, + score: match.score, + badge: + match.workflow.status === "emergent" ? "Emergent" : match.workflow.status === "urgent" ? "Urgent" : undefined, + meta: match.workflow.totalCount ? `${match.workflow.totalCount} differentials` : undefined, + })); +} + async function searchToolsDomain(args: ResolvedSearchArgs): Promise { return rankToolRecords(args.baseQuery, args.limitPerDomain, args.expansions).map((match) => ({ id: match.tool.id, @@ -325,6 +348,7 @@ const domainAdapters: Record< services: { run: searchServicesDomain, timeoutMs: registryDomainTimeoutMs }, forms: { run: searchFormsDomain, timeoutMs: registryDomainTimeoutMs }, differentials: { run: searchDifferentialsDomain, timeoutMs: registryDomainTimeoutMs }, + presentations: { run: searchPresentationsDomain, timeoutMs: registryDomainTimeoutMs }, tools: { run: searchToolsDomain, timeoutMs: registryDomainTimeoutMs }, }; @@ -375,7 +399,7 @@ function preferredLeadDomains(analysis: ClinicalQueryAnalysis): UniversalSearchD case "document_lookup": return ["documents"]; case "comparison": - return ["differentials"]; + return ["differentials", "presentations"]; case "table_threshold": return ["documents", "medications"]; default: @@ -486,7 +510,7 @@ export async function runUniversalSearch(args: RunUniversalSearchArgs): Promise< }; } -export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: string) { +export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: string): string { switch (domain) { case "documents": return documentsSearchHref({ query, run: true }); @@ -497,6 +521,8 @@ export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: case "forms": return `/forms?q=${encodeURIComponent(query)}&run=1`; case "differentials": + // The differentials mode home search composes both kinds, so presentations share it. + case "presentations": return `/differentials?q=${encodeURIComponent(query)}&run=1`; case "tools": return `/?mode=tools&q=${encodeURIComponent(query)}&run=1`; diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts index be7322124..ec5722c22 100644 --- a/tests/differentials.test.ts +++ b/tests/differentials.test.ts @@ -168,6 +168,37 @@ describe("differential records", () => { expect(matches[0]?.workflow.id).toBe("acute-confusion-encephalopathy"); }); + it("surfaces the containing presentation for a candidate diagnosis term", () => { + // "wernicke" is no presentation's own vocabulary; the cross-entity candidates lane links + // the query to the work-up that lists Wernicke encephalopathy as a differential. + const matches = rankPresentationWorkflows(differentialPresentations(), "wernicke"); + expect(matches[0]?.workflow.id).toBe("acute-confusion-encephalopathy"); + expect(matches[0]?.reasons).toContain("candidate differential"); + }); + + it("threads expansions into the presentation ranker's expanded lane", () => { + // A nonsense base query matches nothing on its own… + expect(rankPresentationWorkflows(differentialPresentations(), "zzznotarealterm", 5)).toHaveLength(0); + // …but an expansion term surfaces the matching workflow (parity with rankDifferentialRecords). + const expanded = rankPresentationWorkflows(differentialPresentations(), "zzznotarealterm", 5, ["hallucinations"]); + expect(expanded.some((match) => match.workflow.id === "hallucinations")).toBe(true); + }); + + it("surfaces candidate diagnoses for a presentation-title query", () => { + const workflow = getPresentationWorkflow("acute-confusion-encephalopathy"); + const matches = rankDifferentialRecords( + differentialRecords, + "acute confusion encephalopathy", + differentialRecords.length, + ); + const matchedSlugs = new Set(matches.map((match) => match.record.slug)); + // Every candidate of the matching presentation surfaces, even those whose own titles + // share none of the query vocabulary (e.g. delirium), via the reverse link lane. + expect(workflow?.candidates.every((candidate) => matchedSlugs.has(candidate.slug))).toBe(true); + const delirium = matches.find((match) => match.record.slug === "delirium"); + expect(delirium?.reasons).toContain("presentation link"); + }); + it("does not leak service registry terms", () => { const combinedDifferentialText = JSON.stringify({ differentialRecords, diff --git a/tests/ui-universal-search.spec.ts b/tests/ui-universal-search.spec.ts index e40f82ec7..edf1ff899 100644 --- a/tests/ui-universal-search.spec.ts +++ b/tests/ui-universal-search.spec.ts @@ -39,6 +39,23 @@ const universalPayload = { }, ], }, + { + kind: "presentations", + total: 1, + latencyMs: 3, + items: [ + { + id: "acute-confusion-encephalopathy", + kind: "presentations", + title: "Delirium / Acute Confusion / Encephalopathy", + subtitle: "Delirium and its encephalopathic mimics are acute medical emergencies", + href: "/differentials/presentations/acute-confusion-encephalopathy", + score: 18, + badge: "Emergent", + meta: "7 differentials", + }, + ], + }, ], }; @@ -65,6 +82,21 @@ test.describe("universal search typeahead", () => { await expect(page.getByRole("option", { name: /Acamprosate/ })).toBeVisible(); await expect(page.getByText("Forms · 1")).toBeVisible(); await expect(page.getByRole("option", { name: /View all in Medication/ })).toBeVisible(); + // Presentations render as their own group borrowing the differentials mode target. + await expect(page.getByText("Presentations · 1")).toBeVisible(); + await expect(page.getByRole("option", { name: /Acute Confusion/ })).toBeVisible(); + await expect(page.getByRole("option", { name: /View all in Differentials/ })).toBeVisible(); + }); + + test("selecting a presentation result navigates to the workflow page", async ({ page }) => { + await mockUniversalSearch(page); + const input = await openComposer(page); + await input.fill("acute confusion"); + + const option = page.getByRole("option", { name: /Acute Confusion/ }); + await expect(option).toBeVisible(); + await option.click(); + await expect(page).toHaveURL(/\/differentials\/presentations\/acute-confusion-encephalopathy/); }); test("selecting a grouped result navigates to the record", async ({ page }) => { diff --git a/tests/universal-search.test.ts b/tests/universal-search.test.ts index 26aaec340..076655840 100644 --- a/tests/universal-search.test.ts +++ b/tests/universal-search.test.ts @@ -52,7 +52,7 @@ describe("runUniversalSearch (demo/fixtures path)", () => { demo: true, }); expect(response.groups.map((group) => group.kind)).toEqual( - ["documents", "medications", "services", "forms", "differentials", "tools"].filter((domain) => + ["documents", "medications", "services", "forms", "differentials", "presentations", "tools"].filter((domain) => ["tools", "differentials"].includes(domain), ), ); @@ -78,6 +78,31 @@ describe("runUniversalSearch (demo/fixtures path)", () => { expect(differentials?.error).toBeUndefined(); }); + it("isolates a failing presentations adapter without touching the differentials group", async () => { + vi.doMock("@/lib/differentials", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + rankPresentationWorkflows: () => { + throw new Error("presentations adapter exploded"); + }, + }; + }); + const { runUniversalSearch } = await loadUniversalSearch(); + const response = await runUniversalSearch({ query: "confusion", limitPerDomain: 3, demo: true }); + + const presentations = response.groups.find((group) => group.kind === "presentations"); + expect(presentations?.error).toBe(true); + expect(presentations?.items).toEqual([]); + const differentials = response.groups.find((group) => group.kind === "differentials"); + expect(differentials?.error).toBeUndefined(); + expect(differentials?.items.length ?? 0).toBeGreaterThan(0); + + // doMock registrations survive resetModules, so drop it here or every later test in this + // file would import the throwing presentations ranker. + vi.doUnmock("@/lib/differentials"); + }); + it("uses demo document search when no Supabase client is supplied", async () => { const { runUniversalSearch } = await loadUniversalSearch(); const response = await runUniversalSearch({ query: "clozapine monitoring", limitPerDomain: 4, demo: true }); @@ -178,6 +203,18 @@ describe("GET /api/search/universal (demo mode)", () => { const payload = (await response.json()) as { groups: Array<{ kind: string }> }; expect(payload.groups.map((group) => group.kind)).toEqual(["tools"]); }); + + it("serves the presentations domain through the CSV filter", async () => { + vi.stubEnv("NEXT_PUBLIC_DEMO_MODE", "true"); + const { GET } = await import("../src/app/api/search/universal/route"); + const response = await GET( + new Request("http://localhost/api/search/universal?q=confusion&domains=presentations,bogus"), + ); + expect(response.status).toBe(200); + const payload = (await response.json()) as { groups: Array<{ kind: string; items: Array<{ href: string }> }> }; + expect(payload.groups.map((group) => group.kind)).toEqual(["presentations"]); + expect(payload.groups[0]?.items[0]?.href).toContain("/differentials/presentations/"); + }); }); describe("runUniversalSearch (query intelligence & ranking)", () => { @@ -258,6 +295,63 @@ describe("runUniversalSearch (query intelligence & ranking)", () => { const expanded = rankMedicationRecords(records, "zzznotarealterm", 5, ["clozapine"]); expect(expanded.some((match) => match.medication.name.toLowerCase() === "clozapine")).toBe(true); }); + + it("surfaces both the presentation and its candidate differentials for a symptom query", async () => { + const { runUniversalSearch } = await loadUniversalSearch(); + const response = await runUniversalSearch({ + query: "confusion", + limitPerDomain: 5, + domains: ["differentials", "presentations"], + demo: true, + }); + const presentations = response.groups.find((group) => group.kind === "presentations"); + expect(presentations?.items[0]?.id).toBe("acute-confusion-encephalopathy"); + expect(presentations?.items[0]?.href).toBe("/differentials/presentations/acute-confusion-encephalopathy"); + expect(presentations?.items[0]?.badge).toBe("Emergent"); + // The reverse link lane surfaces the presentation's candidate differentials for the same + // symptom vocabulary even though their own titles never contain "confusion". + const differentials = response.groups.find((group) => group.kind === "differentials"); + const candidateSlugs = new Set([ + "delirium", + "substance-intoxication", + "substance-withdrawal", + "post-ictal-confusion", + "dementia-with-superimposed-delirium", + "wernicke-encephalopathy", + "hepatic-encephalopathy", + ]); + expect(differentials?.items.some((item) => candidateSlugs.has(item.id))).toBe(true); + }); + + it("leads with the presentation for a symptom phrase that whole-phrase-matches its title", async () => { + const { runUniversalSearch } = await loadUniversalSearch(); + const response = await runUniversalSearch({ + query: "acute confusion", + limitPerDomain: 5, + domains: ["differentials", "presentations"], + demo: true, + }); + // No diagnosis title contains the phrase, so only the presentations group is confident and + // it is promoted to lead + Best match. + expect(response.domainOrder?.[0]).toBe("presentations"); + expect(response.topHit?.kind).toBe("presentations"); + expect(response.topHit?.href).toBe("/differentials/presentations/acute-confusion-encephalopathy"); + }); + + it("prefers the exact diagnosis over the umbrella presentation when both titles match", async () => { + const { runUniversalSearch } = await loadUniversalSearch(); + const response = await runUniversalSearch({ + query: "substance intoxication", + limitPerDomain: 5, + domains: ["differentials", "presentations"], + demo: true, + }); + // Both kinds hold a confident whole-phrase title match ("Substance intoxication" is a + // diagnosis and an umbrella presentation); canonical order pins Best match to the precise + // diagnosis page ahead of the umbrella. + expect(response.topHit?.kind).toBe("differentials"); + expect(response.domainOrder?.slice(0, 2)).toEqual(["differentials", "presentations"]); + }); }); // Regression guard: outside an explicit demo/local deploy, no caller — including an anonymous, @@ -314,7 +408,15 @@ describe("GET /api/search/universal (live public/owner path)", () => { // Replace only the entrypoint; the schema still needs the real domain list. vi.doMock("@/lib/universal-search", () => ({ runUniversalSearch, - universalSearchDomains: ["documents", "medications", "services", "forms", "differentials", "tools"], + universalSearchDomains: [ + "documents", + "medications", + "services", + "forms", + "differentials", + "presentations", + "tools", + ], })); }