diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index 2b467a47f0..9322e327c5 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -15,7 +15,13 @@ import { type DifferentialRecordRow, } from "@/lib/differential-records"; import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; -import { differentialRecords, searchDifferentialRecords, searchPresentationWorkflows } from "@/lib/differentials"; +import { + differentialRecords, + rankDifferentialRecords, + rankPresentationWorkflows, + type DifferentialPresentationMatch, + type DifferentialRecordMatch, +} from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; @@ -42,20 +48,31 @@ function differentialResponse(payload: Record) { return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); } +function recordMatchesPayload(matches: DifferentialRecordMatch[]) { + return matches.map((match) => ({ record: match.record, score: match.score, reasons: match.reasons })); +} + +function presentationMatchesPayload(matches: DifferentialPresentationMatch[]) { + return matches.map((match) => ({ workflow: match.workflow, score: match.score, reasons: match.reasons })); +} + function publicDifferentialPayload(kind: DifferentialRecordKind, q: string | undefined, limit: number) { const snapshot = loadDifferentialSnapshot(); const governance = deriveGovernanceFromSnapshot(snapshot); if (kind === "presentation") { - const presentations = q ? searchPresentationWorkflows(q).slice(0, limit) : snapshot.presentations; + const ranked = q ? rankPresentationWorkflows(snapshot.presentations, q, limit) : null; return { - presentations, + presentations: ranked ? ranked.map((match) => match.workflow) : snapshot.presentations, + matches: ranked ? presentationMatchesPayload(ranked) : undefined, total: snapshot.presentations.length, governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, }; } - const records = q ? searchDifferentialRecords(q).slice(0, limit) : differentialRecords; + const ranked = q ? rankDifferentialRecords(differentialRecords, q, limit) : null; + const records = ranked ? ranked.map((match) => match.record) : differentialRecords; return { records, + matches: ranked ? recordMatchesPayload(ranked) : undefined, total: records.length, governance: { sourceStatus: governance.source_status, validationStatus: governance.validation_status }, }; @@ -123,26 +140,20 @@ export async function GET(request: Request) { if (kind === "presentation") { const presentations = rows.map(rowToPresentationWorkflow); - const filtered = q - ? searchPresentationWorkflows(q) - .filter((presentation) => presentations.some((row) => row.id === presentation.id)) - .slice(0, limit) - : presentations; + const ranked = q ? rankPresentationWorkflows(presentations, q, limit) : null; return differentialResponse({ - presentations: filtered, + presentations: ranked ? ranked.map((match) => match.workflow) : presentations, + matches: ranked ? presentationMatchesPayload(ranked) : undefined, total: rows.length, governance: Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])), }); } const records = rows.map(rowToDifferentialRecord); - const filtered = q - ? searchDifferentialRecords(q) - .filter((record) => records.some((row) => row.slug === record.slug)) - .slice(0, limit) - : records; + const ranked = q ? rankDifferentialRecords(records, q, limit) : null; return differentialResponse({ - records: filtered, + records: ranked ? ranked.map((match) => match.record) : records, + matches: ranked ? recordMatchesPayload(ranked) : undefined, total: rows.length, governance: Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])), }); diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index bf1d3affb8..6a74393d22 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1671,6 +1671,10 @@ export function ClinicalDashboard({ return next; }); } + // The query the current documentMatches were fetched for, so the + // differentials results view can tell live-edited catalogue results apart + // from evidence that belongs to a previously submitted search. + const [differentialEvidenceQuery, setDifferentialEvidenceQuery] = useState(null); const clearDifferentialModeResultState = useCallback(() => { resetAnswerThread(); setAnswer(null); @@ -1682,6 +1686,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setError(null); setAnswerProgress(null); + setDifferentialEvidenceQuery(null); }, [resetAnswerThread]); const [scopeFilters, setScopeFilters] = useState({}); const [searchScope, setSearchScope] = useState(null); @@ -2804,6 +2809,11 @@ export function ClinicalDashboard({ try { let successfulPayload: SearchResultModePayload | null = null; let lastError: SearchError | null = null; + // Differentials mode: the ranked catalogue results are the primary + // content and load independently of this document-evidence search, so an + // empty corpus result is applied (empty evidence) rather than surfaced + // as an error that would hide the catalogue view. + let emptyDifferentialsPayload: SearchResultModePayload | null = null; for (const entry of queryPlan) { if (entry.isKeyword) onProgress("Trying keyword-based search..."); @@ -2821,6 +2831,7 @@ export function ClinicalDashboard({ ); if (!resultUsable(payload)) { + if (modeSearch.kind === "differentials") emptyDifferentialsPayload = payload; lastError = makeSearchError("No usable results were found.", 404, false); if (!entry.isKeyword) { continue; @@ -2839,6 +2850,10 @@ export function ClinicalDashboard({ } } + if (!successfulPayload && emptyDifferentialsPayload) { + successfulPayload = emptyDifferentialsPayload; + } + if (!successfulPayload) { if (lastError) throw lastError; throw new Error("Search did not return usable results."); @@ -2847,6 +2862,7 @@ export function ClinicalDashboard({ // M10: discard a stale response — a newer search owns the UI state. if (requestId === searchRequestSeqRef.current) { applySearchResult(successfulPayload, trimmedQuery); + if (isDifferentialsMode) setDifferentialEvidenceQuery(trimmedQuery); if (successfulPayload.kind === "answer") { // The composer is a draft box in a conversation: clear it so the // user can type the next follow-up immediately. @@ -3968,6 +3984,8 @@ export function ClinicalDashboard({ record.slug.includes(fragment))?.[1] ?? BrainCircuit; +function resultIcon(kind: DifferentialResult["kind"], slug: string) { + if (kind === "presentation") return BrainCircuit; + return candidateIconBySlug.find(([fragment]) => slug.includes(fragment))?.[1] ?? Stethoscope; } function tagText(value: string) { @@ -177,53 +180,20 @@ function tagText(value: string) { return cleaned.toLowerCase(); } -function buildDifferentialResults(): DifferentialResult[] { - const workflow = acuteConfusionPresentationWorkflow; - const candidateRows = workflow.candidates.flatMap((candidate, index) => { - const record = getDifferentialRecord(candidate.slug); - if (!record) return []; - const Icon = recordIcon(record); - const whyItFits = candidate.comparison["why-it-fits"] ?? record.clinicalHinge; - const tags = [ - ...record.currentPresentation.slice(0, 2), - ...(index < 2 ? ["fluctuating course"] : []), - record.investigations[0], - ] - .filter(Boolean) - .slice(0, 4) - .map(tagText); - - return [ - { - id: record.slug, - title: record.title, - subtitle: whyItFits, - href: `/differentials/diagnoses/${record.slug}`, - status: record.status, - selected: candidate.selected, - matchLabel: index < 1 ? "High match" : index < 5 ? "Moderate match" : "Lower match", - tags, - icon: Icon, - safety: candidate.comparison["must-not-miss"] ?? whyItFits, - }, - ]; - }); - - return [ - { - id: workflow.id, - title: workflow.title, - subtitle: "Acute presentation with fluctuating course, inattention, or disorientation.", - href: `/differentials/presentations/${workflow.id}`, - status: workflow.status, - selected: true, - matchLabel: "Best match", - tags: ["acute onset", "fluctuating course", "inattention", "disorientation"], - icon: BrainCircuit, - safety: workflow.safetySnapshot.summary, - }, - ...candidateRows, - ].slice(0, 8); +function toDifferentialResult(item: DifferentialSearchResultItem): DifferentialResult { + return { + id: item.id, + kind: item.kind, + title: item.title, + subtitle: item.subtitle, + href: item.href, + status: item.status, + selected: false, + matchLabel: item.matchLabel, + tags: item.tags.map(tagText), + icon: resultIcon(item.kind, item.slug), + safety: item.safety, + }; } function StatusBadge({ status, className }: { status: DifferentialRecord["status"]; className?: string }) { @@ -280,16 +250,17 @@ function SelectionToggle({ selected, onClick, label }: { selected: boolean; onCl function DesktopResultRow({ result, index, + isBest, selected, onToggle, }: { result: DifferentialResult; index: number; + isBest: boolean; selected: boolean; onToggle: () => void; }) { const Icon = result.icon; - const isBest = index === 0; return (
( {tag} ))} - {isBest ? +2 : null} + {result.tags.length > 4 ? {`+${result.tags.length - 4}`} : null}
@@ -368,16 +339,17 @@ function DesktopResultRow({ function MobileResultCard({ result, index, + isBest, selected, onToggle, }: { result: DifferentialResult; index: number; + isBest: boolean; selected: boolean; onToggle: () => void; }) { const Icon = result.icon; - const isBest = index === 0; return (
( {tag} ))} - {isBest ? +2 : null} + {result.tags.length > (isBest ? 4 : 2) ? {`+${result.tags.length - (isBest ? 4 : 2)}`} : null}
); @@ -466,15 +438,12 @@ function BestAnswerCard({ {best.tags.map((tag) => ( {tag} ))} - +2 ); } -function SafetyCard() { - const workflow = acuteConfusionPresentationWorkflow; - +function SafetyCard({ safety, query }: { safety: string; query: string }) { return (
@@ -485,11 +454,9 @@ function SafetyCard() {

Safety first

-

- {workflow.safetySnapshot.summary} -

+

{safety}

View presentation guide @@ -501,12 +468,10 @@ function SafetyCard() { ); } -function LikelyPresentationCard({ best }: { best: DifferentialResult }) { - const points = [ - "Acute onset with fluctuating attention or awareness.", - "Post-operative or medical setting increases risk.", - best.safety ?? acuteConfusionPresentationWorkflow.safetySnapshot.summary, - ]; +function LikelyPresentationCard({ lead }: { lead: DifferentialResult }) { + const points = [lead.subtitle, ...lead.tags, lead.safety] + .filter((point): point is string => Boolean(point?.trim())) + .slice(0, 4); return (
@@ -561,7 +526,6 @@ function SourceStatusCard({ loading: boolean; onRunSourceSearch: () => void; }) { - const workflow = acuteConfusionPresentationWorkflow; const hasSourceEvidence = evidenceState === "source-backed"; return ( @@ -582,7 +546,7 @@ function SourceStatusCard({

- {hasSourceEvidence ? workflow.sourceStatus.label : "Run source search"} + {hasSourceEvidence ? "Imported catalogue" : "Run source search"} {hasSourceEvidence @@ -593,7 +557,7 @@ function SourceStatusCard({

{hasSourceEvidence - ? workflow.sourceStatus.version + ? "Catalogue matches are ranked from the imported, locally reviewed differentials library." : "Showing reviewed local differential records. Run source search to validate against indexed documents."}

{!hasSourceEvidence ? ( @@ -614,6 +578,7 @@ function SourceStatusCard({ function InterpretationRail({ best, results, + query, sourceCount, evidenceState, loading, @@ -621,11 +586,14 @@ function InterpretationRail({ }: { best: DifferentialResult; results: DifferentialResult[]; + query: string; sourceCount: number; evidenceState: DifferentialEvidenceState; loading: boolean; onRunSourceSearch: () => void; }) { + const safetyLead = results.find((result) => result.status === "emergent") ?? best; + return (
+ + + + )} - + {best ? : null}

Clinical decision support only. Review before use. @@ -877,7 +964,9 @@ function SearchResultsView({ export function DifferentialsHome({ query, loading, + searchSubmitted, documentMatches, + evidenceQuery, onQueryChange, onSuggestedSearch, onRunSearch, @@ -887,7 +976,9 @@ export function DifferentialsHome({ }: { query: string; loading: boolean; + searchSubmitted?: boolean; documentMatches?: DocumentMatch[]; + evidenceQuery?: string | null; realDataReady?: boolean; authUnavailable?: boolean; apiUnavailable?: boolean; @@ -936,16 +1027,17 @@ export function DifferentialsHome({ runSearch(action.query); } - // Only surface ranked results once an actual search has run (loading or - // evidence matches present) — not on every keystroke, and not for a query - // whose source search returned nothing. Otherwise the hard-coded demo - // rankings render as if relevant to any typed text. - if (trimmedQuery && (loading || hasEvidenceMatches)) { + // Only surface ranked results once an actual search has run (submitted, + // loading, or evidence matches present) — not on every keystroke. The + // catalogue results are the primary content, so a submitted search with + // zero document evidence still shows the ranked catalogue view. + if (trimmedQuery && (loading || searchSubmitted || hasEvidenceMatches)) { return ( ); @@ -956,7 +1048,7 @@ export function DifferentialsHome({ Open

- {visibleMedicationResults.map((result) => { - const selected = result.id === "acamprosate"; + {visibleMedicationResults.map((result, index) => { + const selected = index === 0 && Boolean(query.trim()); const rowClassName = cn( "grid w-full grid-cols-[minmax(16rem,1.15fr)_minmax(6.5rem,0.42fr)_minmax(8rem,0.48fr)_minmax(16rem,1fr)_2rem] items-center gap-2.5 px-4 py-2.5 text-left transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[color:var(--focus)]", selected @@ -702,8 +702,8 @@ function MedicationResults({ ) : null}
- {visibleMedicationResults.map((result) => { - const selected = result.id === "acamprosate"; + {visibleMedicationResults.map((result, index) => { + const selected = index === 0 && Boolean(query.trim()); const cardClassName = cn( "w-full rounded-lg border bg-[color:var(--surface-raised)] p-2 text-left shadow-[var(--shadow-inset)] transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]", selected diff --git a/src/components/clinical-dashboard/use-differential-catalog.ts b/src/components/clinical-dashboard/use-differential-catalog.ts index 996a4330b2..473fcff8dc 100644 --- a/src/components/clinical-dashboard/use-differential-catalog.ts +++ b/src/components/clinical-dashboard/use-differential-catalog.ts @@ -6,6 +6,19 @@ import type { DifferentialSourceStatus, DifferentialValidationStatus } from "@/l import type { DifferentialPresentationWorkflow, DifferentialRecord } from "@/lib/differentials"; import { useAuthSession } from "@/lib/supabase/client"; +export type DifferentialSearchMatches = { + diagnoses: Array<{ record: DifferentialRecord; score: number; reasons: string[] }>; + presentations: Array<{ workflow: DifferentialPresentationWorkflow; score: number; reasons: string[] }>; +}; + +export type DifferentialSearchState = { + status: "loading" | "ready" | "unauthorized" | "error"; + matches: DifferentialSearchMatches; + demoMode: boolean; +}; + +const emptyDifferentialMatches: DifferentialSearchMatches = { diagnoses: [], presentations: [] }; + export type DifferentialRecordGovernance = { sourceStatus: DifferentialSourceStatus; validationStatus: DifferentialValidationStatus; @@ -27,6 +40,78 @@ export type DifferentialPresentationState = { governance: DifferentialRecordGovernance | null; }; +/** Ranked catalogue search for the Differentials search mode: fetches scored + * diagnosis and presentation matches in parallel from /api/differentials. + * Empty queries resolve immediately without a request. */ +export function useDifferentialSearch(query: string): DifferentialSearchState { + const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); + const requestKey = query.trim().toLowerCase(); + const [state, setState] = useState({ + status: "ready", + matches: emptyDifferentialMatches, + demoMode: false, + }); + // Reset to loading during render when the query changes (repo pattern — + // avoids react-hooks/set-state-in-effect). + const [lastRequestKey, setLastRequestKey] = useState(requestKey); + if (lastRequestKey !== requestKey) { + setLastRequestKey(requestKey); + setState({ + status: requestKey ? "loading" : "ready", + matches: emptyDifferentialMatches, + demoMode: false, + }); + } + + useEffect(() => { + if (!requestKey) return undefined; + let active = true; + const encoded = encodeURIComponent(requestKey); + Promise.all([ + fetch(`/api/differentials?kind=diagnosis&q=${encoded}&limit=20`, { headers: authorizationHeader }), + fetch(`/api/differentials?kind=presentation&q=${encoded}&limit=10`, { headers: authorizationHeader }), + ]) + .then(async ([diagnosisResponse, presentationResponse]) => { + if (!active) return; + if (diagnosisResponse.status === 401 || presentationResponse.status === 401) { + if (authStatus === "loading") return; + if (authStatus === "authenticated") markSessionExpired(); + setState({ status: "unauthorized", matches: emptyDifferentialMatches, demoMode: false }); + return; + } + if (!diagnosisResponse.ok || !presentationResponse.ok) { + setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); + return; + } + const diagnosisPayload = (await diagnosisResponse.json()) as { + matches?: DifferentialSearchMatches["diagnoses"]; + demoMode?: boolean; + }; + const presentationPayload = (await presentationResponse.json()) as { + matches?: DifferentialSearchMatches["presentations"]; + demoMode?: boolean; + }; + if (!active) return; + setState({ + status: "ready", + matches: { + diagnoses: diagnosisPayload.matches ?? [], + presentations: presentationPayload.matches ?? [], + }, + demoMode: Boolean(diagnosisPayload.demoMode || presentationPayload.demoMode), + }); + }) + .catch(() => { + if (active) setState({ status: "error", matches: emptyDifferentialMatches, demoMode: false }); + }); + return () => { + active = false; + }; + }, [requestKey, authStatus, authorizationHeader, markSessionExpired]); + + return state; +} + export function useDifferentialRecord(slug: string): DifferentialRecordState { const { authorizationHeader, markSessionExpired, status: authStatus } = useAuthSession(); const requestKey = slug.trim().toLowerCase(); diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index 20ad2c1436..1976087461 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -34,8 +34,12 @@ export type CatalogMatchSignals = { 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; compact: boolean; phrase: boolean; + prefix: boolean; exact: boolean; broad: boolean; }; @@ -61,6 +65,11 @@ export type RankCatalogOptions = { // Values compared for exact equality with the normalized query (title/slug). exactValues?: (record: T) => string[]; exactBonus?: number; + // Values checked for a starts-with match on the normalized query (partial + // typing of a name). 0 disables. + prefixValues?: (record: T) => string[]; + prefixBonus?: number; + prefixMinLength?: number; // Catalogue-wide "broad intent" terms ("forms", "services") granting a flat bonus. broadTerms?: string[]; broadBonus?: number; @@ -84,6 +93,8 @@ export function rankCatalogRecords( const compactMinLength = options.compactMinLength ?? 4; const phraseBonus = options.phraseBonus ?? 4; const exactBonus = options.exactBonus ?? 10; + const prefixBonus = options.prefixBonus ?? 0; + const prefixMinLength = options.prefixMinLength ?? 3; const broadBonus = options.broadBonus ?? 1; const compactQuery = compactSearchText(normalizedQuery); @@ -91,6 +102,8 @@ export function rankCatalogRecords( const terms = options.expandTokens ? Array.from(new Set(options.expandTokens(baseTerms).filter((term) => term.length > 1))) : baseTerms; + const baseTermSet = new Set(baseTerms); + const expandedTerms = terms.filter((term) => !baseTermSet.has(term)); const broad = Boolean(options.broadTerms?.length && terms.some((term) => options.broadTerms!.includes(term))); const ranked = records @@ -111,6 +124,8 @@ export function rankCatalogRecords( const content = terms.filter((term) => text.includes(term)).length; score += content * contentWeight; + const expanded = expandedTerms.filter((term) => text.includes(term)).length; + const compact = compactBonus > 0 && compactQuery.length >= compactMinLength && @@ -126,13 +141,19 @@ export function rankCatalogRecords( const exact = Boolean(options.exactValues?.(record).some((value) => value === normalizedQuery)); if (exact) score += exactBonus; + const prefix = + prefixBonus > 0 && + normalizedQuery.length >= prefixMinLength && + Boolean(options.prefixValues?.(record).some((value) => value.startsWith(normalizedQuery))); + if (prefix) score += prefixBonus; + if (broad) score += broadBonus; return { record, index, score, - signals: { fields, content, compact, phrase, exact, broad } satisfies CatalogMatchSignals, + signals: { fields, content, expanded, compact, phrase, prefix, exact, broad } satisfies CatalogMatchSignals, }; }) .filter((match) => match.score > 0) diff --git a/src/lib/differentials.ts b/src/lib/differentials.ts index 4bddaf22c0..7707be1de7 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -120,65 +120,258 @@ function expandQueryTerms(terms: string[]) { return [...expanded]; } -function recordSearchText(record: DifferentialRecord) { +const differentialStatusRank: Record = { + emergent: 0, + urgent: 1, + routine: 2, +}; + +export type DifferentialRecordMatch = { + record: DifferentialRecord; + score: number; + reasons: string[]; +}; + +/** Back-compat alias kept for the universal-search workstream naming. */ +export type DifferentialSearchMatch = DifferentialRecordMatch; + +export type DifferentialPresentationMatch = { + workflow: DifferentialPresentationWorkflow; + score: number; + reasons: string[]; +}; + +function diagnosisHingeText(record: DifferentialRecord) { + return normalizeSearchText( + [record.subtitle, record.clinicalHinge, record.safetySnapshot.summary, ...record.safetySnapshot.tags].join(" "), + ); +} + +function diagnosisFullText(record: DifferentialRecord) { return normalizeSearchText( [ + record.title, + record.slug, record.subtitle, record.clinicalHinge, record.safetySnapshot.summary, + ...record.safetySnapshot.tags, ...record.sections.flatMap((section) => [section.title, section.summary, ...section.items]), - ...record.related.flatMap((node) => [node.label, node.note]), + ...record.related.flatMap((node) => [node.label, node.note ?? ""]), + ...record.currentPresentation, + ...record.investigations, + ...record.immediateActions, ].join(" "), ); } -export type DifferentialSearchMatch = { record: DifferentialRecord; score: number; reasons: string[] }; - -export function rankDifferentialRecords(query: string, limit?: number): DifferentialSearchMatch[] { - return rankCatalogRecords(differentialRecords, query, { - fields: [{ id: "title", weight: 6, text: (record) => normalizeSearchText(`${record.title} ${record.slug}`) }], - fullText: recordSearchText, +/** Ranked catalogue search over diagnosis records, built on the shared + * rankCatalogRecords primitive. Records are passed in so the API can rank + * live owner rows and universal search can rank the snapshot with the same + * scoring. Alias expansion (symptom -> diagnosis vocabulary) comes from the + * imported catalogue's searchAliases; urgency shapes ties only, never + * outranking a stronger text match. */ +export function rankDifferentialRecords( + records: DifferentialRecord[], + query: string, + limit = 50, +): DifferentialRecordMatch[] { + return rankCatalogRecords(records, query, { + fields: [ + { id: "title", weight: 8, text: (record) => normalizeSearchText(`${record.title} ${record.slug}`) }, + { id: "hinge", weight: 3, text: diagnosisHingeText }, + ], + fullText: diagnosisFullText, contentWeight: 2, + compactBonus: 6, + compactExtraText: (record) => normalizeSearchText(record.title), phraseBonus: 4, exactValues: (record) => [normalizeSearchText(record.title), normalizeSearchText(record.slug)], exactBonus: 10, expandTokens: expandQueryTerms, limit, - tieBreak: (left, right) => left.title.localeCompare(right.title), + tieBreak: (left, right) => + differentialStatusRank[left.status] - differentialStatusRank[right.status] || + left.title.localeCompare(right.title), }).map(({ record, score, signals }) => ({ record, score, reasons: [ signals.fields.title ? "title" : "", - signals.exact ? "exact title" : "", - signals.content ? "clinical content" : "", + signals.exact || signals.compact ? "exact name" : "", + signals.fields.hinge ? "clinical hinge/safety" : "", + signals.content ? "content" : "", + signals.expanded ? "symptom alias" : "", ].filter(Boolean), })); } -export function searchDifferentialRecords(query: string) { - // Empty query keeps the full-catalogue browse behaviour; otherwise results are now - // relevance-ranked (previously an unranked alias OR-filter in snapshot order). - if (!normalizeSearchText(query)) return differentialRecords; - return rankDifferentialRecords(query).map((match) => match.record); +function presentationSafetyText(workflow: DifferentialPresentationWorkflow) { + return normalizeSearchText([workflow.subtitle, ...workflow.safetySnapshot.tags].join(" ")); } -export function searchPresentationWorkflows(query: string) { - if (!normalizeSearchText(query)) return differentialPresentations(); - return rankCatalogRecords(differentialPresentations(), query, { - fields: [{ id: "title", weight: 6, text: (presentation) => normalizeSearchText(presentation.title) }], - fullText: (presentation) => - normalizeSearchText( - [ - presentation.subtitle, - presentation.safetySnapshot.summary, - ...presentation.safetySnapshot.tags, - ...presentation.candidates.map((candidate) => candidate.slug), - ].join(" "), - ), +function presentationFullText(workflow: DifferentialPresentationWorkflow) { + return normalizeSearchText( + [ + workflow.title, + workflow.id, + workflow.subtitle, + ...workflow.safetySnapshot.tags, + workflow.safetySnapshot.summary, + workflow.highestUrgencyNote, + ...workflow.reviewChecklist, + ...workflow.candidates.map((candidate) => candidate.slug.replace(/-/g, " ")), + ].join(" "), + ); +} + +/** Ranked catalogue search over presentation workflows (same scoring family + * as rankDifferentialRecords, weighted towards safety tags). */ +export function rankPresentationWorkflows( + workflows: DifferentialPresentationWorkflow[], + query: string, + limit = 20, +): DifferentialPresentationMatch[] { + return rankCatalogRecords(workflows, query, { + fields: [ + { id: "title", weight: 8, text: (workflow) => normalizeSearchText(`${workflow.title} ${workflow.id}`) }, + { id: "safety", weight: 4, text: presentationSafetyText }, + ], + fullText: presentationFullText, contentWeight: 2, + compactBonus: 6, + compactExtraText: (workflow) => normalizeSearchText(workflow.title), phraseBonus: 4, + exactValues: (workflow) => [normalizeSearchText(workflow.title), normalizeSearchText(workflow.id)], + exactBonus: 10, expandTokens: expandQueryTerms, - tieBreak: (left, right) => left.title.localeCompare(right.title), - }).map((match) => match.record); + limit, + tieBreak: (left, right) => + differentialStatusRank[left.status] - differentialStatusRank[right.status] || + left.title.localeCompare(right.title), + }).map(({ record, score, signals }) => ({ + workflow: record, + score, + reasons: [ + signals.fields.title ? "title" : "", + signals.exact || signals.compact ? "exact name" : "", + signals.fields.safety ? "safety focus" : "", + signals.content ? "content" : "", + signals.expanded ? "symptom alias" : "", + ].filter(Boolean), + })); +} + +export type DifferentialSearchResultItem = { + id: string; + kind: "presentation" | "diagnosis"; + slug: string; + title: string; + subtitle: string; + href: string; + status: DifferentialRecord["status"]; + score: number; + matchLabel: "Best match" | "High match" | "Moderate match" | "Lower match"; + tags: string[]; + safety: string; + reasons: string[]; +}; + +function diagnosisResultItem(match: DifferentialRecordMatch): Omit { + const { record, score, reasons } = match; + return { + id: record.slug, + kind: "diagnosis", + slug: record.slug, + title: record.title, + subtitle: record.clinicalHinge || record.subtitle, + href: `/differentials/diagnoses/${record.slug}`, + status: record.status, + score, + tags: [...record.currentPresentation.slice(0, 3), record.investigations[0]] + .filter((value): value is string => Boolean(value?.trim())) + .slice(0, 4), + safety: record.safetySnapshot.summary, + reasons, + }; +} + +function presentationResultItem( + match: DifferentialPresentationMatch, +): Omit { + const { workflow, score, reasons } = match; + return { + id: workflow.id, + kind: "presentation", + slug: workflow.id, + title: workflow.title, + subtitle: workflow.subtitle, + href: `/differentials/presentations/${workflow.id}`, + status: workflow.status, + score, + tags: workflow.safetySnapshot.tags.slice(0, 4), + safety: workflow.safetySnapshot.summary, + reasons, + }; +} + +/** Compose ranked diagnosis + presentation matches into one adaptive result + * list: when a presentation matches about as strongly as the best diagnosis + * it leads (followed by its candidate diagnoses in ranked order), otherwise + * results interleave purely by score. Deduped by id, capped at `limit`. */ +export function composeDifferentialSearchResults( + diagnoses: DifferentialRecordMatch[], + presentations: DifferentialPresentationMatch[], + limit = 8, +): DifferentialSearchResultItem[] { + const items: Array> = []; + const seen = new Set(); + const push = (item: Omit) => { + const key = `${item.kind}:${item.id}`; + if (seen.has(key)) return; + seen.add(key); + items.push(item); + }; + + const topPresentation = presentations[0]; + const topDiagnosisScore = diagnoses[0]?.score ?? 0; + const presentationLeads = + Boolean(topPresentation) && (topDiagnosisScore === 0 || topPresentation!.score >= topDiagnosisScore * 0.8); + + if (topPresentation && presentationLeads) { + push(presentationResultItem(topPresentation)); + const candidateSlugs = new Set(topPresentation.workflow.candidates.map((candidate) => candidate.slug)); + for (const match of diagnoses) { + if (candidateSlugs.has(match.record.slug)) push(diagnosisResultItem(match)); + } + for (const match of diagnoses) push(diagnosisResultItem(match)); + for (const match of presentations.slice(1)) push(presentationResultItem(match)); + } else { + const merged = [ + ...diagnoses.map((match) => ({ score: match.score, item: diagnosisResultItem(match) })), + ...presentations.map((match) => ({ score: match.score, item: presentationResultItem(match) })), + ].sort((left, right) => right.score - left.score); + for (const entry of merged) push(entry.item); + } + + return items.slice(0, limit).map((item, index) => ({ + ...item, + matchLabel: + index === 0 ? "Best match" : item.score >= 12 ? "High match" : item.score >= 6 ? "Moderate match" : "Lower match", + })); +} + +/** Back-compat wrapper: empty query returns the full catalogue (the API route + * relies on this), otherwise relevance-ranked results in ranked order. */ +export function searchDifferentialRecords(query: string) { + if (!normalizeSearchText(query)) return differentialRecords; + return rankDifferentialRecords(differentialRecords, query, differentialRecords.length).map((match) => match.record); +} + +/** Back-compat wrapper: empty query returns all presentations, otherwise + * ranked results in ranked order. */ +export function searchPresentationWorkflows(query: string) { + const presentations = differentialPresentations(); + if (!normalizeSearchText(query)) return presentations; + return rankPresentationWorkflows(presentations, query, presentations.length).map((match) => match.workflow); } diff --git a/src/lib/medications.ts b/src/lib/medications.ts index 4c4873c2a4..7b47135fc8 100644 --- a/src/lib/medications.ts +++ b/src/lib/medications.ts @@ -213,6 +213,8 @@ export function rankMedicationRecords(records: MedicationRecord[], query: string phraseBonus: 4, exactValues: (medication) => [normalizeSearchText(medication.name), normalizeSearchText(medication.slug)], exactBonus: 10, + prefixValues: (medication) => [normalizeSearchText(medication.name), normalizeSearchText(medication.slug)], + prefixBonus: 5, limit, tieBreak: (left, right) => left.name.localeCompare(right.name), }).map(({ record, score, signals }) => ({ @@ -220,6 +222,7 @@ export function rankMedicationRecords(records: MedicationRecord[], query: string score, reasons: [ signals.fields.name ? "name" : "", + signals.prefix ? "name prefix" : "", signals.compact ? "exact name" : "", signals.fields.taxonomy ? "class/category" : "", signals.content ? "content" : "", diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index ee2e791509..90e005a172 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -1,7 +1,7 @@ import { demoSearch } from "@/lib/demo-data"; import { fetchRelatedDocuments } from "@/lib/document-enrichment"; import { documentsSearchHref } from "@/lib/document-flow-routes"; -import { rankDifferentialRecords } from "@/lib/differentials"; +import { differentialRecords, rankDifferentialRecords } from "@/lib/differentials"; import { formRecords, rankFormRecords, type FormRecord } from "@/lib/forms"; import { rowToMedicationRecord } from "@/lib/medication-records"; import { defaultMedicationRecords, fetchOwnerMedicationRowsWithSeed } from "@/lib/medication-seed"; @@ -161,7 +161,7 @@ async function searchFormsDomain(args: RunUniversalSearchArgs): Promise { // Differentials are a static snapshot for list/search purposes (owner edits surface only on // detail pages today), so demo and live share the in-bundle catalogue. - return rankDifferentialRecords(args.query, args.limitPerDomain).map((match) => ({ + return rankDifferentialRecords(differentialRecords, args.query, args.limitPerDomain).map((match) => ({ id: match.record.slug, kind: "differentials", title: match.record.title, diff --git a/tests/differentials-route.test.ts b/tests/differentials-route.test.ts index 160fafb88c..e5720b3d8f 100644 --- a/tests/differentials-route.test.ts +++ b/tests/differentials-route.test.ts @@ -153,6 +153,7 @@ describe("differentials API routes", () => { const response = await GET(request("/api/differentials?kind=diagnosis&limit=10")); const payload = (await response.json()) as { records?: Array<{ slug: string }>; + matches?: unknown; total?: number; demoMode?: boolean; }; @@ -161,5 +162,44 @@ describe("differentials API routes", () => { expect(payload.demoMode).toBe(true); expect((payload.total ?? 0) > 100).toBe(true); expect(payload.records?.length).toBeGreaterThan(0); + expect(payload.matches).toBeUndefined(); + }); + + it("returns scored diagnosis matches for a query", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/differentials/route"); + + const response = await GET(request("/api/differentials?kind=diagnosis&q=delirium&limit=10")); + const payload = (await response.json()) as { + records?: Array<{ slug: string }>; + matches?: Array<{ record: { slug: string }; score: number; reasons: string[] }>; + }; + + expect(response.status).toBe(200); + expect(payload.matches?.[0]?.record.slug).toBe("delirium"); + expect(payload.matches?.[0]?.score ?? 0).toBeGreaterThan(0); + expect(payload.matches?.[0]?.reasons).toContain("title"); + // Ranked records stay in ranked order and mirror the matches list. + expect(payload.records?.[0]?.slug).toBe("delirium"); + expect(payload.records?.length).toBe(payload.matches?.length); + }); + + it("returns scored presentation matches for a query", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/differentials/route"); + + const response = await GET(request("/api/differentials?kind=presentation&q=acute%20confusion&limit=5")); + const payload = (await response.json()) as { + presentations?: Array<{ id: string }>; + matches?: Array<{ workflow: { id: string }; score: number; reasons: string[] }>; + }; + + expect(response.status).toBe(200); + expect(payload.matches?.[0]?.workflow.id).toBe("acute-confusion-encephalopathy"); + expect(payload.matches?.[0]?.score ?? 0).toBeGreaterThan(0); + expect(payload.presentations?.[0]?.id).toBe("acute-confusion-encephalopathy"); + expect(payload.presentations?.length).toBeLessThanOrEqual(5); }); }); diff --git a/tests/differentials.test.ts b/tests/differentials.test.ts index 3198b8fe06..d6eb8d7a8e 100644 --- a/tests/differentials.test.ts +++ b/tests/differentials.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import { parseEntryFile, parseScenarioPresets, parseSearchAliases } from "../scripts/lib/parse-differentials-export"; import { + composeDifferentialSearchResults, differentialDiagnosesCards, + differentialPresentations, differentialPresentationsCards, differentialRecords, differentialStaticParams, @@ -10,7 +12,12 @@ import { getPresentationWorkflow, loadDifferentialSnapshot, rankDifferentialRecords, + rankPresentationWorkflows, searchDifferentialRecords, + searchPresentationWorkflows, + type DifferentialPresentationMatch, + type DifferentialRecord, + type DifferentialRecordMatch, } from "@/lib/differentials"; const deliriumEntry = `=== ENTRY 1 === @@ -117,6 +124,38 @@ describe("differential records", () => { expect(searchDifferentialRecords("delirium").length).toBeGreaterThan(0); }); + it("ranks exact diagnosis matches first with reasons", () => { + const matches = rankDifferentialRecords(differentialRecords, "delirium"); + expect(matches[0]?.record.slug).toBe("delirium"); + expect(matches[0]?.reasons).toContain("title"); + expect(matches[0]?.score ?? 0).toBeGreaterThan(0); + // Ranked order is monotonic by score. + for (let index = 1; index < matches.length; index += 1) { + expect(matches[index - 1]!.score).toBeGreaterThanOrEqual(matches[index]!.score); + } + }); + + it("surfaces symptom-alias matches from the imported alias table", () => { + // "confused" expands via the catalogue searchAliases to confusion/delirium/ + // encephalopathy, so the delirium record matches through the alias path. + const matches = rankDifferentialRecords(differentialRecords, "confused"); + const delirium = matches.find((match) => match.record.slug === "delirium"); + expect(delirium).toBeDefined(); + expect(delirium?.reasons).toContain("symptom alias"); + }); + + it("returns no ranked matches for an empty query but keeps the legacy full-set contract", () => { + expect(rankDifferentialRecords(differentialRecords, " ")).toEqual([]); + expect(rankPresentationWorkflows(differentialPresentations(), "")).toEqual([]); + expect(searchDifferentialRecords("").length).toBe(differentialRecords.length); + expect(searchPresentationWorkflows("").length).toBe(differentialPresentations().length); + }); + + it("ranks the acute confusion presentation first for its own vocabulary", () => { + const matches = rankPresentationWorkflows(differentialPresentations(), "acute confusion"); + expect(matches[0]?.workflow.id).toBe("acute-confusion-encephalopathy"); + }); + it("does not leak service registry terms", () => { const combinedDifferentialText = JSON.stringify({ differentialRecords, @@ -138,9 +177,94 @@ describe("differential records", () => { }); }); +function makeRecord(slug: string, status: DifferentialRecord["status"] = "routine"): DifferentialRecord { + return { + slug, + title: slug, + status, + subtitle: `${slug} subtitle`, + clinicalHinge: `${slug} hinge`, + safetySnapshot: { summary: `${slug} safety`, tags: [] }, + sections: [], + related: [], + currentPresentation: [`${slug} presentation feature`], + investigations: [`${slug} test`], + immediateActions: [], + }; +} + +function diagnosisMatch(slug: string, score: number): DifferentialRecordMatch { + return { record: makeRecord(slug), score, reasons: ["title"] }; +} + +function presentationMatch(id: string, score: number, candidateSlugs: string[]): DifferentialPresentationMatch { + return { + workflow: { + id, + title: id, + status: "emergent", + subtitle: `${id} subtitle`, + selectedCount: 0, + totalCount: candidateSlugs.length, + safetySnapshot: { summary: `${id} safety`, tags: ["tag-one"] }, + criteria: [], + candidates: candidateSlugs.map((slug) => ({ slug, selected: false, comparison: {} })), + reviewChecklist: [], + highestUrgencyNote: "", + sourceStatus: { label: "", version: "", lastUpdated: "" }, + }, + score, + reasons: ["title"], + }; +} + +describe("composeDifferentialSearchResults", () => { + it("leads with a presentation when it matches about as strongly as the best diagnosis", () => { + const results = composeDifferentialSearchResults( + [diagnosisMatch("alpha", 10), diagnosisMatch("beta", 8)], + [presentationMatch("workflow-one", 9, ["beta"])], + ); + expect(results[0]).toMatchObject({ kind: "presentation", id: "workflow-one", matchLabel: "Best match" }); + // Candidate diagnoses of the lead presentation come before other diagnoses. + expect(results[1]).toMatchObject({ kind: "diagnosis", id: "beta" }); + expect(results[2]).toMatchObject({ kind: "diagnosis", id: "alpha" }); + }); + + it("leads with diagnoses when the presentation match is weak", () => { + const results = composeDifferentialSearchResults( + [diagnosisMatch("alpha", 20)], + [presentationMatch("workflow-one", 3, [])], + ); + expect(results[0]).toMatchObject({ kind: "diagnosis", id: "alpha" }); + expect(results[1]).toMatchObject({ kind: "presentation", id: "workflow-one" }); + }); + + it("dedupes by id, caps at the limit, and tiers match labels", () => { + const diagnoses = Array.from({ length: 12 }, (_, index) => diagnosisMatch(`dx-${index}`, 20 - index)); + const results = composeDifferentialSearchResults([...diagnoses, diagnosisMatch("dx-0", 20)], []); + expect(results).toHaveLength(8); + expect(new Set(results.map((result) => result.id)).size).toBe(8); + expect(results[0]?.matchLabel).toBe("Best match"); + expect(results[1]?.matchLabel).toBe("High match"); + const lowest = composeDifferentialSearchResults([diagnosisMatch("a", 9), diagnosisMatch("b", 4)], []); + expect(lowest[1]?.matchLabel).toBe("Lower match"); + }); + + it("maps hrefs to the catalogue detail pages", () => { + const results = composeDifferentialSearchResults( + [diagnosisMatch("alpha", 10)], + [presentationMatch("workflow-one", 10, [])], + ); + const presentation = results.find((result) => result.kind === "presentation"); + const diagnosis = results.find((result) => result.kind === "diagnosis"); + expect(presentation?.href).toBe("/differentials/presentations/workflow-one"); + expect(diagnosis?.href).toBe("/differentials/diagnoses/alpha"); + }); +}); + describe("ranked differential search", () => { it("ranks title matches above content-only matches", () => { - const matches = rankDifferentialRecords("delirium"); + const matches = rankDifferentialRecords(differentialRecords, "delirium"); expect(matches.length).toBeGreaterThan(0); expect(matches[0].record.slug).toContain("delirium"); expect(matches[0].score).toBeGreaterThanOrEqual(matches[matches.length - 1].score); diff --git a/tests/medications.test.ts b/tests/medications.test.ts index a9840998c9..8f8a61bff9 100644 --- a/tests/medications.test.ts +++ b/tests/medications.test.ts @@ -18,6 +18,13 @@ describe("medications catalogue", () => { expect(matches[0]?.score).toBeGreaterThan(0); }); + it("boosts name-prefix matches above content-only matches", () => { + const records = loadMedicationSnapshot(); + const matches = rankMedicationRecords(records, "sert", 10); + expect(matches[0]?.medication.slug).toBe("sertraline"); + expect(matches[0]?.reasons).toContain("name prefix"); + }); + it("exposes prescribing summary fields for search results", () => { const record = getMedicationRecord("acamprosate"); expect(record).toBeTruthy(); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index f466e56cc6..46fbc36d36 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -825,11 +825,12 @@ test.describe("Clinical KB tools launcher", () => { queryMode: "compare_guidance", }); - // Evidence arrived, so the results view renders — with the synthetic - // demonstration-content notice, never presented as reviewed output. + // Evidence arrived, so the results view renders — ranked from the imported + // differentials catalogue with a real query-matched result row. await expect(page.getByTestId("differentials-search-results")).toBeVisible(); - await expect(page.getByTestId("differentials-demo-content-notice")).toBeVisible(); - await expect(page.getByText("Demonstration ranking").first()).toBeVisible(); + await expect(page.getByTestId("differentials-catalogue-notice")).toBeVisible(); + await expect(page.getByText("Catalogue ranking").first()).toBeVisible(); + await expect(page.getByRole("link", { name: "Delirium / Acute Confusion / Encephalopathy" }).first()).toBeVisible(); }); test("differentials presentation comparison page stays wired to differentials mode", async ({ page }) => { @@ -1013,11 +1014,25 @@ test.describe("Responsive layout guards", () => { const home = page.getByTestId("medication-home"); await expect(home).toBeVisible(); await settleLayout(page); - return page.evaluate(() => { - const rect = document.querySelector('[data-testid="medication-home"]')?.getBoundingClientRect(); - if (!rect) return null; - return { topGap: rect.top, bottomGap: window.innerHeight - rect.bottom }; - }); + const measure = () => + page.evaluate(() => { + const rect = document.querySelector('[data-testid="medication-home"]')?.getBoundingClientRect(); + if (!rect) return null; + return { topGap: rect.top, bottomGap: window.innerHeight - rect.bottom }; + }); + // The smart-search hint/prompt rows render at first paint and are hidden + // by a post-hydration check on phone, shrinking the measured home ~50px + // shortly after load. Poll until two consecutive measurements match so + // the guard asserts the settled layout, not the transient one. + let result = await measure(); + await expect(async () => { + const next = await measure(); + const stable = + result !== null && next !== null && result.topGap === next.topGap && result.bottomGap === next.bottomGap; + result = next; + expect(stable).toBe(true); + }).toPass({ timeout: 10_000 }); + return result; } // Phone (< sm): content is top-aligned so integrated action menus are not