From cd1a2f7a56b41870eae6ef298692f3441bd1291b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:10:03 +0000 Subject: [PATCH 1/4] Wire differentials, services and medications catalogues into search modes Differentials search mode previously rendered a hard-coded acute-confusion demo fixture regardless of the query, and never touched the imported 232-record catalogue. Now: - Add scored, alias-aware rankers (rankDifferentialRecords, rankPresentationWorkflows) and an adaptive composer that leads with a presentation workflow when it matches about as strongly as the best diagnosis, otherwise interleaves by score. - Return scored `matches` from GET /api/differentials for both kinds and rank live owner rows directly (replacing the snapshot-intersect). - Add useDifferentialSearch hook and rewire the Differentials search view to render real query-driven catalogue results with loading/empty/error states, functional kind filters, and accurate catalogue copy. - Accept empty document-evidence payloads in differentials mode instead of erroring, and gate the results view on search submission. - Medications: replace the hard-coded acamprosate row highlight and filter literals with rank-driven equivalents; add a name-prefix boost. - Services already searched the imported catalogue; unchanged. Verified: focused vitest suites, verify:cheap (1108 tests), ui-tools.spec.ts chromium (36 passed) against the live dev server. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015UuyyMMegXxTeEJsyR741t --- src/app/api/differentials/route.ts | 43 +- src/components/ClinicalDashboard.tsx | 11 + .../clinical-dashboard/differentials-home.tsx | 535 ++++++++++-------- .../medication-prescribing-workspace.tsx | 14 +- .../use-differential-catalog.ts | 85 +++ src/lib/differentials.ts | 326 ++++++++++- src/lib/medications.ts | 5 + tests/differentials-route.test.ts | 40 ++ tests/differentials.test.ts | 125 ++++ tests/medications.test.ts | 7 + tests/ui-tools.spec.ts | 9 +- 11 files changed, 910 insertions(+), 290 deletions(-) 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 d449062956..553fbb7199 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -2770,6 +2770,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..."); @@ -2787,6 +2792,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; @@ -2805,6 +2811,10 @@ export function ClinicalDashboard({ } } + if (!successfulPayload && emptyDifferentialsPayload) { + successfulPayload = emptyDifferentialsPayload; + } + if (!successfulPayload) { if (lastError) throw lastError; throw new Error("Search did not return usable results."); @@ -3934,6 +3944,7 @@ 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 }) { @@ -339,7 +309,7 @@ function DesktopResultRow({ {result.tags.slice(0, 4).map((tag) => ( {tag} ))} - {isBest ? +2 : null} + {result.tags.length > 4 ? {`+${result.tags.length - 4}`} : null}
@@ -427,7 +397,7 @@ function MobileResultCard({ {result.tags.slice(0, isBest ? 4 : 2).map((tag) => ( {tag} ))} - {isBest ? +2 : null} + {result.tags.length > (isBest ? 4 : 2) ? {`+${result.tags.length - (isBest ? 4 : 2)}`} : null}
); @@ -466,15 +436,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 +452,9 @@ function SafetyCard() {

Safety first

-

- {workflow.safetySnapshot.summary} -

+

{safety}

View presentation guide @@ -501,12 +466,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 +524,6 @@ function SourceStatusCard({ loading: boolean; onRunSourceSearch: () => void; }) { - const workflow = acuteConfusionPresentationWorkflow; const hasSourceEvidence = evidenceState === "source-backed"; return ( @@ -582,7 +544,7 @@ function SourceStatusCard({

- {hasSourceEvidence ? workflow.sourceStatus.label : "Run source search"} + {hasSourceEvidence ? "Imported catalogue" : "Run source search"} {hasSourceEvidence @@ -593,7 +555,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 +576,7 @@ function SourceStatusCard({ function InterpretationRail({ best, results, + query, sourceCount, evidenceState, loading, @@ -621,11 +584,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,6 +946,7 @@ function SearchResultsView({ export function DifferentialsHome({ query, loading, + searchSubmitted, documentMatches, onQueryChange, onSuggestedSearch, @@ -887,6 +957,7 @@ export function DifferentialsHome({ }: { query: string; loading: boolean; + searchSubmitted?: boolean; documentMatches?: DocumentMatch[]; realDataReady?: boolean; authUnavailable?: boolean; @@ -936,11 +1007,11 @@ 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 ( 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/differentials.ts b/src/lib/differentials.ts index 497df76999..3c699b2e59 100644 --- a/src/lib/differentials.ts +++ b/src/lib/differentials.ts @@ -108,41 +108,305 @@ function expandQueryTokens(query: string) { return [...expanded]; } -function recordSearchText(record: DifferentialRecord) { - return [ - record.title, - record.subtitle, - record.clinicalHinge, - record.safetySnapshot.summary, - ...record.sections.flatMap((section) => [section.title, section.summary, ...section.items]), - ...record.related.flatMap((node) => [node.label, node.note]), - ] - .join(" ") - .toLowerCase(); +function normalizeSearchText(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); } +const differentialStatusRank: Record = { + emergent: 0, + urgent: 1, + routine: 2, +}; + +export type DifferentialRecordMatch = { + record: DifferentialRecord; + score: number; + reasons: string[]; +}; + +export type DifferentialPresentationMatch = { + workflow: DifferentialPresentationWorkflow; + score: number; + reasons: string[]; +}; + +type QueryTermPlan = { + normalizedQuery: string; + compactQuery: string; + terms: string[]; + aliasTerms: string[]; +}; + +function buildQueryTermPlan(query: string): QueryTermPlan | null { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return null; + const terms = Array.from(new Set(normalizedQuery.split(/\s+/).filter((term) => term.length > 1))); + const aliasTerms = Array.from( + new Set( + expandQueryTokens(query) + .map(normalizeSearchText) + .filter((term) => term.length > 1 && !terms.includes(term)), + ), + ); + return { + normalizedQuery, + compactQuery: normalizedQuery.replace(/\s+/g, ""), + terms, + aliasTerms, + }; +} + +/** Ranked catalogue search over diagnosis records. Records are passed in so + * the API can rank live owner rows and the client can rank snapshot data with + * the same scoring. Alias expansion (symptom -> diagnosis vocabulary) comes + * from the imported catalogue's searchAliases. */ +export function rankDifferentialRecords( + records: DifferentialRecord[], + query: string, + limit = 50, +): DifferentialRecordMatch[] { + const plan = buildQueryTermPlan(query); + if (!plan) return []; + const { normalizedQuery, compactQuery, terms, aliasTerms } = plan; + + return records + .map((record) => { + const title = normalizeSearchText(record.title); + const slug = normalizeSearchText(record.slug); + const hingeText = normalizeSearchText( + [record.subtitle, record.clinicalHinge, record.safetySnapshot.summary, ...record.safetySnapshot.tags].join(" "), + ); + const contentText = normalizeSearchText( + [ + ...record.sections.flatMap((section) => [section.title, section.summary, ...section.items]), + ...record.related.flatMap((node) => [node.label, node.note ?? ""]), + ...record.currentPresentation, + ...record.investigations, + ...record.immediateActions, + ].join(" "), + ); + const text = `${title} ${slug} ${hingeText} ${contentText}`; + + const titleMatches = terms.filter((term) => title.includes(term) || slug.includes(term)); + const hingeMatches = terms.filter((term) => hingeText.includes(term)); + const contentMatches = terms.filter((term) => contentText.includes(term)); + const aliasMatches = aliasTerms.filter((term) => text.includes(term)); + const exactName = title === normalizedQuery || slug === normalizedQuery; + const compactTitleMatch = compactQuery.length >= 4 && title.replace(/\s+/g, "").includes(compactQuery); + + let score = 0; + score += titleMatches.length * 8; + if (exactName) score += 10; + if (compactTitleMatch) score += 6; + score += hingeMatches.length * 3; + score += contentMatches.length * 2; + score += aliasMatches.length * 2; + if (text.includes(normalizedQuery)) score += 4; + // Safety-first tie shaping only: a small nudge so equal-evidence matches + // surface must-not-miss diagnoses first, never enough to outrank a + // stronger text match. + if (score > 0 && record.status === "emergent") score += 2; + else if (score > 0 && record.status === "urgent") score += 1; + + const reasons = [ + titleMatches.length ? "title" : "", + exactName || compactTitleMatch ? "exact name" : "", + hingeMatches.length ? "clinical hinge/safety" : "", + contentMatches.length ? "content" : "", + aliasMatches.length ? "symptom alias" : "", + score > 0 && record.status !== "routine" ? "urgency" : "", + ].filter(Boolean); + + return { record, score, reasons }; + }) + .filter((match) => match.score > 0) + .sort( + (left, right) => + right.score - left.score || + differentialStatusRank[left.record.status] - differentialStatusRank[right.record.status] || + left.record.title.localeCompare(right.record.title), + ) + .slice(0, limit); +} + +/** Ranked catalogue search over presentation workflows (same scoring family + * as rankDifferentialRecords, weighted towards safety tags and candidates). */ +export function rankPresentationWorkflows( + workflows: DifferentialPresentationWorkflow[], + query: string, + limit = 20, +): DifferentialPresentationMatch[] { + const plan = buildQueryTermPlan(query); + if (!plan) return []; + const { normalizedQuery, compactQuery, terms, aliasTerms } = plan; + + return workflows + .map((workflow) => { + const title = normalizeSearchText(workflow.title); + const id = normalizeSearchText(workflow.id); + const safetyText = normalizeSearchText([workflow.subtitle, ...workflow.safetySnapshot.tags].join(" ")); + const contentText = normalizeSearchText( + [ + workflow.safetySnapshot.summary, + workflow.highestUrgencyNote, + ...workflow.reviewChecklist, + ...workflow.candidates.map((candidate) => candidate.slug.replace(/-/g, " ")), + ].join(" "), + ); + const text = `${title} ${id} ${safetyText} ${contentText}`; + + const titleMatches = terms.filter((term) => title.includes(term) || id.includes(term)); + const safetyMatches = terms.filter((term) => safetyText.includes(term)); + const contentMatches = terms.filter((term) => contentText.includes(term)); + const aliasMatches = aliasTerms.filter((term) => text.includes(term)); + const exactName = title === normalizedQuery || id === normalizedQuery; + const compactTitleMatch = compactQuery.length >= 4 && title.replace(/\s+/g, "").includes(compactQuery); + + let score = 0; + score += titleMatches.length * 8; + if (exactName) score += 10; + if (compactTitleMatch) score += 6; + score += safetyMatches.length * 4; + score += contentMatches.length * 2; + score += aliasMatches.length * 2; + if (text.includes(normalizedQuery)) score += 4; + if (score > 0 && workflow.status === "emergent") score += 2; + else if (score > 0 && workflow.status === "urgent") score += 1; + + const reasons = [ + titleMatches.length ? "title" : "", + exactName || compactTitleMatch ? "exact name" : "", + safetyMatches.length ? "safety focus" : "", + contentMatches.length ? "content" : "", + aliasMatches.length ? "symptom alias" : "", + score > 0 && workflow.status !== "routine" ? "urgency" : "", + ].filter(Boolean); + + return { workflow, score, reasons }; + }) + .filter((match) => match.score > 0) + .sort( + (left, right) => + right.score - left.score || + differentialStatusRank[left.workflow.status] - differentialStatusRank[right.workflow.status] || + left.workflow.title.localeCompare(right.workflow.title), + ) + .slice(0, limit); +} + +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 ranked results in ranked order. */ export function searchDifferentialRecords(query: string) { - const tokens = expandQueryTokens(query); - if (!tokens.length) return differentialRecords; - return differentialRecords.filter((record) => { - const text = recordSearchText(record); - return tokens.some((token) => text.includes(token)); - }); + 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 tokens = expandQueryTokens(query); - if (!tokens.length) return differentialPresentations(); - return differentialPresentations().filter((presentation) => { - const text = [ - presentation.title, - presentation.subtitle, - presentation.safetySnapshot.summary, - ...presentation.safetySnapshot.tags, - ...presentation.candidates.map((candidate) => candidate.slug), - ] - .join(" ") - .toLowerCase(); - return tokens.some((token) => text.includes(token)); - }); + 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 d04a3eea84..7438c34f6b 100644 --- a/src/lib/medications.ts +++ b/src/lib/medications.ts @@ -218,9 +218,13 @@ export function rankMedicationRecords(records: MedicationRecord[], query: string compactQuery.length >= 4 && (compactText.includes(compactQuery) || title.replace(/\s+/g, "").includes(compactQuery)); + const namePrefixMatch = + normalizedQuery.length >= 3 && (title.startsWith(normalizedQuery) || slug.startsWith(normalizedQuery)); + let score = 0; score += titleMatches.length * 8; if (compactTitleMatch) score += 6; + if (namePrefixMatch) score += 5; score += taxonomyMatches.length * 3; score += matchedTerms.length * 2; if (normalizedQuery && text.includes(normalizedQuery)) score += 4; @@ -228,6 +232,7 @@ export function rankMedicationRecords(records: MedicationRecord[], query: string const reasons = [ titleMatches.length ? "name" : "", + namePrefixMatch ? "name prefix" : "", compactTitleMatch ? "exact name" : "", taxonomyMatches.length ? "class/category" : "", matchedTerms.length ? "content" : "", 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 a04b826e25..467d5f9fe5 100644 --- a/tests/differentials.test.ts +++ b/tests/differentials.test.ts @@ -2,14 +2,22 @@ import { describe, expect, it } from "vitest"; import { parseEntryFile, parseScenarioPresets, parseSearchAliases } from "../scripts/lib/parse-differentials-export"; import { + composeDifferentialSearchResults, differentialDiagnosesCards, + differentialPresentations, differentialPresentationsCards, differentialRecords, differentialStaticParams, getDifferentialRecord, getPresentationWorkflow, loadDifferentialSnapshot, + rankDifferentialRecords, + rankPresentationWorkflows, searchDifferentialRecords, + searchPresentationWorkflows, + type DifferentialPresentationMatch, + type DifferentialRecord, + type DifferentialRecordMatch, } from "@/lib/differentials"; const deliriumEntry = `=== ENTRY 1 === @@ -116,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, @@ -136,3 +176,88 @@ 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"); + }); +}); 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..27d53f8f9c 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 }) => { From eda08d6f5c26b4d5849d6bf5c3cf09c8c4649366 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:36:15 +0000 Subject: [PATCH 2/4] Stabilise prescribing home gap guard against composer settle race The phone gap assertion measured medication-home one animation frame after visibility, racing the post-hydration check that hides the smart-search hint/prompt rows (restored in #318) and shrinks the portaled composer from 111px to 61px. CI measured the transient state (bottomGap 110.95 < topGap 134) and failed deterministically; fresh local loads reproduced it 3 in 4 runs. Poll until two consecutive measurements match so the guard asserts the settled layout at both widths. Verified 5/5 passes with --repeat-each=5 against a dev server running with the CI env vars. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015UuyyMMegXxTeEJsyR741t --- tests/ui-tools.spec.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index 27d53f8f9c..46fbc36d36 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1014,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 From 7a4016916d5ac5d4cfb90509f84b89db0ffeb79a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 12:46:40 +0000 Subject: [PATCH 3/4] Fix differentials search stale evidence, filter best-match, and failure UI - Hide source evidence when the composer query no longer matches the query that produced documentMatches - Style and rank rows by global catalogue position, not filtered-list index - Skip empty-match UI when catalogue search fails so only the alert shows --- .../clinical-dashboard/differentials-home.tsx | 68 ++++++++++++------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index d8882790cb..876375508c 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -250,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 (
void; }) { const Icon = result.icon; - const isBest = index === 0; return (
void; }) { const catalog = useDifferentialSearch(query); + const documentMatchesKey = useMemo( + () => documentMatches?.map((match) => match.document_id).join("|") ?? "", + [documentMatches], + ); + const [sourceEvidenceQuery, setSourceEvidenceQuery] = useState(null); + const [lastDocumentMatchesKey, setLastDocumentMatchesKey] = useState(documentMatchesKey); + if (lastDocumentMatchesKey !== documentMatchesKey) { + setLastDocumentMatchesKey(documentMatchesKey); + setSourceEvidenceQuery(documentMatchesKey ? query.trim() : null); + } + const activeDocumentMatches = + sourceEvidenceQuery && sourceEvidenceQuery === query.trim() ? documentMatches : undefined; const results = useMemo( () => composeDifferentialSearchResults(catalog.matches.diagnoses, catalog.matches.presentations).map( @@ -652,11 +666,11 @@ function SearchResultsView({ const visibleResults = kindFilter === "all" ? results : results.filter((result) => result.kind === kindFilter); const best = results[0] ?? null; const selectedCount = selectedIds.size; - const hasSourceEvidence = Boolean(documentMatches?.length); + const hasSourceEvidence = Boolean(activeDocumentMatches?.length); const evidenceState: DifferentialEvidenceState = hasSourceEvidence ? "source-backed" : "guided"; // Count the sources that actually matched this search, never the whole // indexed library - the surrounding copy states these reflect real matches. - const reviewedSourceCount = hasSourceEvidence ? (documentMatches?.length ?? 0) : 0; + const reviewedSourceCount = hasSourceEvidence ? (activeDocumentMatches?.length ?? 0) : 0; const catalogLoading = catalog.status === "loading"; const catalogFailed = catalog.status === "error" || catalog.status === "unauthorized"; @@ -726,7 +740,7 @@ function SearchResultsView({ /> ))}
- ) : !best ? ( + ) : !best && !catalogFailed ? (
- {visibleResults.map((result, index) => ( -
-
- toggleSelected(result.id)} - /> + {visibleResults.map((result) => { + const globalIndex = results.findIndex((entry) => entry.id === result.id); + const isBest = result.id === best.id; + return ( +
+
+ toggleSelected(result.id)} + /> +
+
+ toggleSelected(result.id)} + /> +
-
- toggleSelected(result.id)} - /> -
-
- ))} + ); + })}
Date: Mon, 6 Jul 2026 12:51:35 +0000 Subject: [PATCH 4/4] Address Bugbot review: evidence staleness, filtered best-row, failure state - Track the query the current document evidence was fetched for and treat evidence from a different query as pending, so live-edited catalogue results never render under a stale "Source-backed" panel. - Style the overall top-ranked result as best match instead of whichever row is first in a kind-filtered list. - Render a distinct catalogue-failure card (retry/browse links, alert role) instead of showing the "No catalogue matches" empty state when the catalogue fetch errors or is unauthorized. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015UuyyMMegXxTeEJsyR741t --- src/components/ClinicalDashboard.tsx | 7 ++ .../clinical-dashboard/differentials-home.tsx | 101 +++++++++++------- 2 files changed, 67 insertions(+), 41 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 553fbb7199..ac3eec4b18 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1648,6 +1648,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); @@ -1659,6 +1663,7 @@ export function ClinicalDashboard({ setSourceGovernanceWarnings([]); setError(null); setAnswerProgress(null); + setDifferentialEvidenceQuery(null); }, [resetAnswerThread]); const [scopeFilters, setScopeFilters] = useState({}); const [searchScope, setSearchScope] = useState(null); @@ -2823,6 +2828,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. @@ -3945,6 +3951,7 @@ export function ClinicalDashboard({ query={query} loading={loading} searchSubmitted={modeSearchSubmitted} + evidenceQuery={differentialEvidenceQuery} documentMatches={documentMatches} realDataReady={canRunSearch} authUnavailable={false} diff --git a/src/components/clinical-dashboard/differentials-home.tsx b/src/components/clinical-dashboard/differentials-home.tsx index d8882790cb..c0b1c11dbf 100644 --- a/src/components/clinical-dashboard/differentials-home.tsx +++ b/src/components/clinical-dashboard/differentials-home.tsx @@ -250,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 (
void; }) { const Icon = result.icon; - const isBest = index === 0; return (
void; }) { const catalog = useDifferentialSearch(query); @@ -652,11 +656,16 @@ function SearchResultsView({ const visibleResults = kindFilter === "all" ? results : results.filter((result) => result.kind === kindFilter); const best = results[0] ?? null; const selectedCount = selectedIds.size; - const hasSourceEvidence = Boolean(documentMatches?.length); + // Catalogue results follow composer edits live, but document evidence only + // updates on an executed source search — treat evidence fetched for a + // different query as pending so the two panels never claim to be in sync. + const evidenceIsCurrent = (evidenceQuery ?? "").trim().toLowerCase() === query.trim().toLowerCase(); + const currentDocumentMatches = evidenceIsCurrent ? documentMatches : undefined; + const hasSourceEvidence = Boolean(currentDocumentMatches?.length); const evidenceState: DifferentialEvidenceState = hasSourceEvidence ? "source-backed" : "guided"; // Count the sources that actually matched this search, never the whole // indexed library - the surrounding copy states these reflect real matches. - const reviewedSourceCount = hasSourceEvidence ? (documentMatches?.length ?? 0) : 0; + const reviewedSourceCount = hasSourceEvidence ? (currentDocumentMatches?.length ?? 0) : 0; const catalogLoading = catalog.status === "loading"; const catalogFailed = catalog.status === "error" || catalog.status === "unauthorized"; @@ -707,16 +716,6 @@ function SearchResultsView({ library.

- {catalogFailed ? ( -

- {catalog.status === "unauthorized" - ? "Sign in again to search the differentials catalogue." - : "The differentials catalogue could not be searched. Retry shortly or browse the catalogue pages below."} -

- ) : null} {catalogLoading ? (
{[0, 1, 2].map((placeholder) => ( @@ -728,18 +727,28 @@ function SearchResultsView({
) : !best ? (

- No catalogue matches for “{query}” + {catalogFailed + ? catalog.status === "unauthorized" + ? "Sign in again to search the differentials catalogue" + : "The differentials catalogue could not be searched" + : `No catalogue matches for “${query}”`}

- {hasSourceEvidence - ? `No imported differential matched this search, but ${reviewedSourceCount.toLocaleString()} indexed source ${ - reviewedSourceCount === 1 ? "match is" : "matches are" - } available in the library.` - : "Try a symptom, presentation, or diagnosis name — or browse the catalogue directly."} + {catalogFailed + ? "Retry the search shortly, or browse the catalogue pages directly." + : hasSourceEvidence + ? `No imported differential matched this search, but ${reviewedSourceCount.toLocaleString()} indexed source ${ + reviewedSourceCount === 1 ? "match is" : "matches are" + } available in the library.` + : "Try a symptom, presentation, or diagnosis name — or browse the catalogue directly."}

- {visibleResults.map((result, index) => ( -
-
- toggleSelected(result.id)} - /> + {visibleResults.map((result, index) => { + // "Best" styling follows the overall top-ranked result, not + // whichever row happens to be first in a kind-filtered list. + const isBest = result.kind === best.kind && result.id === best.id; + return ( +
+
+ toggleSelected(result.id)} + /> +
+
+ toggleSelected(result.id)} + /> +
-
- toggleSelected(result.id)} - /> -
-
- ))} + ); + })}
);