Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<AppModeId, UniversalSearchDomain>> = {
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<Record<AppModeId, UniversalSearchDomain[]>> = {
documents: ["documents"],
prescribing: ["medications"],
services: ["services"],
forms: ["forms"],
differentials: ["differentials", "presentations"],
tools: ["tools"],
};

const modeIdByDomain: Record<UniversalSearchDomain, AppModeId> = {
Expand All @@ -39,6 +41,9 @@ const modeIdByDomain: Record<UniversalSearchDomain, AppModeId> = {
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",
};

Expand All @@ -48,6 +53,7 @@ const domainHeadings: Record<UniversalSearchDomain, string> = {
services: "Services",
forms: "Forms",
differentials: "Differentials",
presentations: "Presentations",
tools: "Tools",
};

Expand Down Expand Up @@ -666,11 +672,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,
Expand Down
8 changes: 4 additions & 4 deletions src/components/clinical-dashboard/use-universal-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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),
Expand Down
57 changes: 56 additions & 1 deletion src/lib/differentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,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<string, string> | null = null;

function diagnosisTitleBySlug(): Map<string, string> {
diagnosisTitleBySlugCache ??= new Map(differentialRecords.map((record) => [record.slug, record.title]));
return diagnosisTitleBySlugCache;
}

let presentationTitleTextBySlugCache: Map<string, string> | 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<DifferentialRecord["status"], number> = {
emergent: 0,
urgent: 1,
Expand Down Expand Up @@ -277,6 +318,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,
Expand All @@ -297,6 +343,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),
Expand Down Expand Up @@ -328,11 +375,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,
Expand All @@ -341,7 +395,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] ||
Expand All @@ -353,6 +407,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),
Expand Down
23 changes: 23 additions & 0 deletions src/lib/universal-search-domains.ts
Original file line number Diff line number Diff line change
@@ -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",
];
52 changes: 39 additions & 13 deletions src/lib/universal-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -25,16 +31,10 @@ import type { ClinicalQueryAnalysis, SearchResult } from "@/lib/types";

type AdminClient = ReturnType<typeof import("@/lib/supabase/admin").createAdminClient>;

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;
Expand Down Expand Up @@ -215,6 +215,29 @@ async function searchDifferentialsDomain(args: ResolvedSearchArgs): Promise<Univ
);
}

async function searchPresentationsDomain(args: ResolvedSearchArgs): Promise<UniversalSearchItem[]> {
// 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<UniversalSearchItem[]> {
return rankToolRecords(args.baseQuery, args.limitPerDomain, args.expansions).map((match) => ({
id: match.tool.id,
Expand Down Expand Up @@ -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 },
};

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 });
Expand All @@ -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`;
Expand Down
31 changes: 31 additions & 0 deletions tests/differentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,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,
Expand Down
Loading