From 443df1035a2b861b83a46383a5140e55683fed7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:04:36 +0000 Subject: [PATCH 1/6] Add cross-mode entity quick links to answer search results When an answer-mode question names an entity that lives in another app mode (a medication, service, form, or differential), render an "Also in your library" card strip under the answer linking to that entity's detail page, with a secondary action that re-runs the search inside the matching mode. - src/lib/cross-mode-links.ts: pure matcher over the existing per-mode rankers with name/title-level precision gates so question filler ("dose", "patient") never surfaces junk; per-mode and total caps, services/forms slug dedupe. Differential catalog is injected so the module never pulls the snapshot JSONs into the dashboard bundle. - src/lib/cross-mode-differentials.ts: dynamic-import-only adapter that maps the differentials snapshot to the matcher's catalog shape. - src/lib/keyword-query.ts: keyword extraction hoisted from search-utils so lib code can reuse it; search-utils re-exports. - CrossModeLinksStrip component follows the RelatedDocumentsPanel card conventions; rendered above it on final answers only. - Catalogs come from the same owner-scoped APIs the modes already use (fixtures in demo mode) and are fetched lazily after the first answer; useMedicationCatalog gains an `enabled` option. - Follow-up turns fall back to the prior question so the entity card persists through "what about renal impairment?"-style turns. - Vitest coverage for gates/caps/dedupe/aliases plus an answer-mode Playwright smoke test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nt7UBgqg82NEPinWUZ6PXF --- src/components/ClinicalDashboard.tsx | 49 ++++ .../clinical-dashboard/cross-mode-links.tsx | 86 +++++++ .../clinical-dashboard/search-utils.ts | 89 +------ .../use-medication-catalog.ts | 17 +- src/lib/cross-mode-differentials.ts | 20 ++ src/lib/cross-mode-links.ts | 233 ++++++++++++++++++ src/lib/keyword-query.ts | 91 +++++++ tests/cross-mode-links.test.ts | 124 ++++++++++ tests/ui-smoke.spec.ts | 25 ++ 9 files changed, 642 insertions(+), 92 deletions(-) create mode 100644 src/components/clinical-dashboard/cross-mode-links.tsx create mode 100644 src/lib/cross-mode-differentials.ts create mode 100644 src/lib/cross-mode-links.ts create mode 100644 src/lib/keyword-query.ts create mode 100644 tests/cross-mode-links.test.ts diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index d449062956..003947efce 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -63,7 +63,9 @@ import { useAuthSession } from "@/lib/supabase/client"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; +import { CrossModeLinksStrip } from "@/components/clinical-dashboard/cross-mode-links"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; +import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; @@ -160,6 +162,7 @@ import { import { documentsSearchHref } from "@/lib/document-flow-routes"; import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; +import { buildCrossModeLinks, type CrossModeDifferentialCatalog } from "@/lib/cross-mode-links"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { @@ -1579,6 +1582,49 @@ export function ClinicalDashboard({ [searchMode, formSearchMatches, serviceSearchMatches], ); const recordSearchMode = searchMode === "forms" ? "forms" : "services"; + // Cross-mode quick links rendered under answers. Catalogs come from the + // same owner-scoped APIs the modes themselves use (fixtures in demo mode), + // and nothing is fetched until the first answer lands. + const answerCrossLinksActive = activeModeResultKind === "answer" && answer !== null; + const crossLinkServices = useRegistryRecords("service", { enabled: answerCrossLinksActive }); + const crossLinkForms = useRegistryRecords("form", { enabled: answerCrossLinksActive }); + const crossLinkMedications = useMedicationCatalog(undefined, { enabled: answerCrossLinksActive }); + const [crossLinkDifferentials, setCrossLinkDifferentials] = useState(null); + useEffect(() => { + // Dynamic import keeps the 1.2 MB differentials snapshot out of the + // dashboard bundle; the catalog is loaded once per session. + if (!answerCrossLinksActive || crossLinkDifferentials) return; + let cancelled = false; + import("@/lib/cross-mode-differentials").then((module) => { + if (!cancelled) setCrossLinkDifferentials(module.crossModeDifferentialCatalog()); + }); + return () => { + cancelled = true; + }; + }, [answerCrossLinksActive, crossLinkDifferentials]); + const crossModeLinks = useMemo(() => { + if (!answerCrossLinksActive || !latestAnswerQuery) return []; + const catalogs = { + medications: crossLinkMedications.data?.records ?? [], + services: crossLinkServices.records, + forms: crossLinkForms.records, + differentials: crossLinkDifferentials ?? undefined, + }; + const links = buildCrossModeLinks(latestAnswerQuery, catalogs); + if (links.length > 0) return links; + // Follow-ups often drop the entity name ("what about renal impairment?"); + // fall back to the previous turn's question so the entity's card persists. + const priorQuery = priorAnswerTurns.at(-1)?.query; + return priorQuery ? buildCrossModeLinks(priorQuery, catalogs) : links; + }, [ + answerCrossLinksActive, + latestAnswerQuery, + priorAnswerTurns, + crossLinkMedications.data, + crossLinkServices.records, + crossLinkForms.records, + crossLinkDifferentials, + ]); // The thread mirror ref must never outlive the answer it describes: every // reset path nulls `answer`, so clearing here covers them all (mode // switches, new chat, differentials/services clears) without each caller @@ -4085,6 +4131,9 @@ export function ClinicalDashboard({ {showSystemNotice && answer ? renderSystemNotice("sm:hidden") : null} + {activeModeResultKind === "answer" && answer && crossModeLinks.length > 0 && ( + + )} {activeModeResultKind === "answer" && answer && ( void; +}) { + if (links.length === 0) return null; + + return ( +
+

Also in your library

+
+ {links.map((link) => { + const Icon = appModeIcons[link.modeId]; + return ( +
+ + + +
+ + {link.title} + + {link.subtitle ?

{link.subtitle}

: null} + {link.badges.length > 0 && ( +
+ {link.badges.map((badge) => ( + + {badge.label} + + ))} +
+ )} +
+ {link.modeLabel} + + +
+ ); + })} +
+
+ ); +} diff --git a/src/components/clinical-dashboard/search-utils.ts b/src/components/clinical-dashboard/search-utils.ts index c2720968ac..ae99c0400d 100644 --- a/src/components/clinical-dashboard/search-utils.ts +++ b/src/components/clinical-dashboard/search-utils.ts @@ -1,5 +1,7 @@ import type { RagAnswer } from "@/lib/types"; +export { keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; + export type AnswerPayload = RagAnswer & { demoMode?: boolean }; export type SearchError = Error & { @@ -10,73 +12,6 @@ export type SearchError = Error & { export const searchRetryDelaysMs = [500, 1000, 2000] as const; export const searchRetryCount = 2; -const keywordStopWords = new Set([ - "a", - "about", - "all", - "an", - "and", - "are", - "as", - "at", - "be", - "before", - "both", - "by", - "can", - "could", - "did", - "do", - "does", - "for", - "from", - "get", - "had", - "has", - "have", - "her", - "his", - "how", - "if", - "in", - "is", - "it", - "its", - "into", - "me", - "may", - "more", - "my", - "no", - "not", - "of", - "on", - "or", - "our", - "out", - "should", - "so", - "such", - "that", - "the", - "their", - "them", - "there", - "these", - "they", - "this", - "those", - "to", - "when", - "where", - "which", - "who", - "why", - "with", - "would", - "you", -]); - export function makeSearchError(message: string, status?: number, retryable = false): SearchError { const error = new Error(message) as SearchError; error.status = status; @@ -120,26 +55,6 @@ export function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } -export function keywordQueryFromNaturalLanguage(query: string) { - const normalized = query - .normalize("NFKD") - .toLowerCase() - .replace(/[^\w\s]+/g, " ") - .replace(/_/g, " ") - .trim(); - const tokens = normalized.split(/\s+/).filter((token) => token.length >= 3 && !keywordStopWords.has(token)); - const terms: string[] = []; - const seen = new Set(); - - for (const token of tokens) { - if (seen.has(token)) continue; - seen.add(token); - terms.push(token); - } - - return terms.slice(0, 7).join(" "); -} - export function answerPayloadIsUsable(payload: AnswerPayload) { const answerText = payload.answer.trim(); if (!answerText) return false; diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index 63271e7116..eef35c3141 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -41,25 +41,32 @@ async function fetchJson(url: string): Promise { return (await response.json()) as T; } -export function useMedicationCatalog(query?: string): AsyncState { +export function useMedicationCatalog( + query?: string, + options: { enabled?: boolean } = {}, +): AsyncState { + const enabled = options.enabled ?? true; const trimmed = query?.trim() ?? ""; const [prevQuery, setPrevQuery] = useState(trimmed); + const [prevEnabled, setPrevEnabled] = useState(enabled); const [state, setState] = useState>({ data: null, - loading: true, + loading: enabled, error: null, }); - if (trimmed !== prevQuery) { + if (trimmed !== prevQuery || enabled !== prevEnabled) { setPrevQuery(trimmed); + setPrevEnabled(enabled); setState({ data: null, - loading: true, + loading: enabled, error: null, }); } useEffect(() => { + if (!enabled) return; let cancelled = false; const url = trimmed ? `/api/medications?q=${encodeURIComponent(trimmed)}` : "/api/medications"; fetchJson(url) @@ -78,7 +85,7 @@ export function useMedicationCatalog(query?: string): AsyncState { cancelled = true; }; - }, [trimmed]); + }, [trimmed, enabled]); return state; } diff --git a/src/lib/cross-mode-differentials.ts b/src/lib/cross-mode-differentials.ts new file mode 100644 index 0000000000..c95589f365 --- /dev/null +++ b/src/lib/cross-mode-differentials.ts @@ -0,0 +1,20 @@ +import type { CrossModeDifferentialCatalog } from "@/lib/cross-mode-links"; +import { differentialPresentations, differentialRecords, differentialSearchAliases } from "@/lib/differentials"; + +// Load this module with a dynamic import only: it statically pulls the 1.2 MB +// differentials snapshot, which stays code-split out of the dashboard bundle. +export function crossModeDifferentialCatalog(): CrossModeDifferentialCatalog { + return { + diagnoses: differentialRecords.map((record) => ({ + slug: record.slug, + title: record.title, + clinicalHinge: record.clinicalHinge, + })), + presentations: differentialPresentations().map((presentation) => ({ + id: presentation.id, + title: presentation.title, + subtitle: presentation.subtitle, + })), + aliases: differentialSearchAliases(), + }; +} diff --git a/src/lib/cross-mode-links.ts b/src/lib/cross-mode-links.ts new file mode 100644 index 0000000000..8e4cae506f --- /dev/null +++ b/src/lib/cross-mode-links.ts @@ -0,0 +1,233 @@ +import { appModeDefinition, appModeHomeHref, type AppModeId } from "@/lib/app-modes"; +import { rankFormRecords, type FormRecord } from "@/lib/forms"; +import { + medicationIdentityBadges, + medicationIndication, + rankMedicationRecords, + type MedicationRecord, +} from "@/lib/medications"; +import { extractKeywordTerms } from "@/lib/keyword-query"; +import { rankServiceRecords, type ServiceRecord } from "@/lib/services"; + +export type CrossModeLinkModeId = Extract; + +export type CrossModeLinkBadge = { + label: string; + tone?: "clinical" | "success" | "danger" | "warning" | "neutral" | "info"; +}; + +export type CrossModeLink = { + modeId: CrossModeLinkModeId; + modeLabel: string; + slug: string; + title: string; + subtitle: string; + badges: CrossModeLinkBadge[]; + detailHref: string; + modeSearchHref: string; + modeSearchQuery: string; + score: number; + matchReason: string; +}; + +export type CrossModeDifferentialCatalog = { + diagnoses: Array<{ slug: string; title: string; clinicalHinge: string }>; + presentations: Array<{ id: string; title: string; subtitle: string }>; + aliases: Record; +}; + +// The differential catalog is injected (not imported) so this module never +// statically pulls the 1.2 MB differentials snapshot — or the 3.4 MB +// medications snapshot — into the dashboard bundle. +export type CrossModeCatalogs = { + medications?: MedicationRecord[]; + services?: ServiceRecord[]; + forms?: FormRecord[]; + differentials?: CrossModeDifferentialCatalog; +}; + +export type CrossModeLinkOptions = { + maxPerMode?: number; + maxTotal?: number; +}; + +// The gate for every mode is "the query names the entity" (a name/title-level +// match), not raw score: question filler like "dose" or "patient" survives +// keyword extraction and content-matches nearly every record for ~2 points per +// term, so content-only scores can never be trusted on their own. +const MEDICATION_MIN_SCORE = 10; // one name-term hit: 8 (name) + 2 (content echo) +const SERVICE_MIN_SCORE = 8; // one title-term hit: 6 (title) + 2 (content echo) +const DIFFERENTIAL_TITLE_TERM_SCORE = 8; + +const RANKER_CANDIDATE_LIMIT = 5; + +const modePriority: Record = { + prescribing: 0, + services: 1, + forms: 2, + differentials: 3, +}; + +function crossModeLinkBase(modeId: CrossModeLinkModeId, title: string) { + return { + modeId, + modeLabel: appModeDefinition(modeId).label, + title, + modeSearchHref: appModeHomeHref(modeId, { query: title, focus: true, run: true }), + modeSearchQuery: title, + }; +} + +function serviceChipBadges(record: ServiceRecord): CrossModeLinkBadge[] { + const badges: CrossModeLinkBadge[] = []; + for (const chip of record.statusChips ?? []) { + const label = chip.label?.trim(); + if (!label) continue; + badges.push({ label, tone: chip.tone ?? undefined }); + if (badges.length === 2) break; + } + return badges; +} + +function medicationLinks(query: string, records: MedicationRecord[]): CrossModeLink[] { + return rankMedicationRecords(records, query, RANKER_CANDIDATE_LIMIT) + .filter( + (match) => + match.score >= MEDICATION_MIN_SCORE && + (match.reasons.includes("name") || match.reasons.includes("exact name")), + ) + .map((match) => ({ + ...crossModeLinkBase("prescribing", match.medication.name), + slug: match.medication.slug, + subtitle: medicationIndication(match.medication), + badges: medicationIdentityBadges(match.medication).slice(0, 2), + detailHref: `/medications/${match.medication.slug}`, + score: match.score, + matchReason: match.reasons.join(" · "), + })); +} + +function registryLinks( + modeId: Extract, + query: string, + records: ServiceRecord[], +): CrossModeLink[] { + const ranker = modeId === "services" ? rankServiceRecords : rankFormRecords; + return ranker(records, query, RANKER_CANDIDATE_LIMIT) + .filter((match) => match.score >= SERVICE_MIN_SCORE && match.reasons.includes("title")) + .map((match) => ({ + ...crossModeLinkBase(modeId, match.service.title), + slug: match.service.slug, + subtitle: match.service.subtitle?.trim() || match.service.route?.trim() || "", + badges: serviceChipBadges(match.service), + detailHref: `/${modeId}/${match.service.slug}`, + score: match.score, + matchReason: match.reasons.join(" · "), + })); +} + +function differentialTitleScore(title: string, terms: string[], aliasDerived: Set) { + const normalizedTitle = title.toLowerCase(); + // Bare `includes()` on short tokens invites substring junk, so a matching + // term must be at least 4 chars unless it came from a curated alias. + const matches = terms.filter( + (term) => (term.length >= 4 || aliasDerived.has(term)) && normalizedTitle.includes(term), + ); + return matches.length * DIFFERENTIAL_TITLE_TERM_SCORE; +} + +function differentialLinks(terms: string[], catalog: CrossModeDifferentialCatalog): CrossModeLink[] { + if (terms.length === 0) return []; + + const aliasDerived = new Set(); + const expanded = new Set(terms); + for (const term of terms) { + for (const alias of catalog.aliases[term] ?? []) { + const normalizedAlias = alias.toLowerCase(); + if (!expanded.has(normalizedAlias)) aliasDerived.add(normalizedAlias); + expanded.add(normalizedAlias); + } + } + const expandedTerms = [...expanded]; + + const candidates: CrossModeLink[] = []; + for (const record of catalog.diagnoses) { + const score = differentialTitleScore(record.title, expandedTerms, aliasDerived); + if (score < DIFFERENTIAL_TITLE_TERM_SCORE) continue; + candidates.push({ + ...crossModeLinkBase("differentials", record.title), + slug: record.slug, + subtitle: record.clinicalHinge, + badges: [], + detailHref: `/differentials/diagnoses/${record.slug}`, + score, + matchReason: "title", + }); + } + for (const presentation of catalog.presentations) { + const score = differentialTitleScore(presentation.title, expandedTerms, aliasDerived); + if (score < DIFFERENTIAL_TITLE_TERM_SCORE) continue; + candidates.push({ + ...crossModeLinkBase("differentials", presentation.title), + slug: presentation.id, + subtitle: presentation.subtitle, + badges: [], + detailHref: `/differentials/presentations/${presentation.id}`, + score, + matchReason: "title", + }); + } + + return candidates.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title)).slice(0, 1); +} + +export function buildCrossModeLinks( + query: string, + catalogs: CrossModeCatalogs, + options: CrossModeLinkOptions = {}, +): CrossModeLink[] { + const maxPerMode = options.maxPerMode ?? 2; + const maxTotal = options.maxTotal ?? 4; + + const terms = extractKeywordTerms(query); + if (terms.length === 0) return []; + const keywordQuery = terms.join(" "); + + const candidates = [ + ...medicationLinks(keywordQuery, catalogs.medications ?? []), + ...registryLinks("services", keywordQuery, catalogs.services ?? []), + ...registryLinks("forms", keywordQuery, catalogs.forms ?? []), + ...(catalogs.differentials ? differentialLinks(terms, catalogs.differentials) : []), + ]; + + candidates.sort( + (left, right) => + right.score - left.score || + modePriority[left.modeId] - modePriority[right.modeId] || + left.title.localeCompare(right.title), + ); + + const seenKeys = new Set(); + // A slug shared between the services and forms registries is the same + // record surfaced twice; keep only the higher-scoring occurrence. + const seenRegistrySlugs = new Set(); + const perModeCounts: Partial> = {}; + const links: CrossModeLink[] = []; + + for (const candidate of candidates) { + if (links.length >= maxTotal) break; + const key = `${candidate.modeId}:${candidate.slug}`; + if (seenKeys.has(key)) continue; + if (candidate.modeId === "services" || candidate.modeId === "forms") { + if (seenRegistrySlugs.has(candidate.slug)) continue; + seenRegistrySlugs.add(candidate.slug); + } + const modeCount = perModeCounts[candidate.modeId] ?? 0; + if (modeCount >= maxPerMode) continue; + seenKeys.add(key); + perModeCounts[candidate.modeId] = modeCount + 1; + links.push(candidate); + } + + return links; +} diff --git a/src/lib/keyword-query.ts b/src/lib/keyword-query.ts new file mode 100644 index 0000000000..e8edae7bd8 --- /dev/null +++ b/src/lib/keyword-query.ts @@ -0,0 +1,91 @@ +export const keywordStopWords = new Set([ + "a", + "about", + "all", + "an", + "and", + "are", + "as", + "at", + "be", + "before", + "both", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "get", + "had", + "has", + "have", + "her", + "his", + "how", + "if", + "in", + "is", + "it", + "its", + "into", + "me", + "may", + "more", + "my", + "no", + "not", + "of", + "on", + "or", + "our", + "out", + "should", + "so", + "such", + "that", + "the", + "their", + "them", + "there", + "these", + "they", + "this", + "those", + "to", + "when", + "where", + "which", + "who", + "why", + "with", + "would", + "you", +]); + +export function extractKeywordTerms(query: string, options: { maxTerms?: number } = {}): string[] { + const maxTerms = options.maxTerms ?? 12; + const normalized = query + .normalize("NFKD") + .toLowerCase() + .replace(/[^\w\s]+/g, " ") + .replace(/_/g, " ") + .trim(); + const tokens = normalized.split(/\s+/).filter((token) => token.length >= 3 && !keywordStopWords.has(token)); + const terms: string[] = []; + const seen = new Set(); + + for (const token of tokens) { + if (seen.has(token)) continue; + seen.add(token); + terms.push(token); + } + + return terms.slice(0, maxTerms); +} + +export function keywordQueryFromNaturalLanguage(query: string) { + return extractKeywordTerms(query, { maxTerms: 7 }).join(" "); +} diff --git a/tests/cross-mode-links.test.ts b/tests/cross-mode-links.test.ts new file mode 100644 index 0000000000..7f9405e9bf --- /dev/null +++ b/tests/cross-mode-links.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { crossModeDifferentialCatalog } from "@/lib/cross-mode-differentials"; +import { buildCrossModeLinks } from "@/lib/cross-mode-links"; +import { extractKeywordTerms, keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; +import { defaultMedicationRecords } from "@/lib/medication-fixtures"; +import type { ServiceRecord } from "@/lib/services"; + +const medications = defaultMedicationRecords(); +const differentials = crossModeDifferentialCatalog(); + +const homeTreatmentTeam: ServiceRecord = { + slug: "adult-home-treatment-team", + title: "Adult Home Treatment Team", + subtitle: "Intensive home-based acute care", + statusChips: [{ label: "Acute", tone: "info" }], + tags: ["home treatment"], +}; + +// Matches "adult" and "treatment" via tags only — must stay below the +// title-reason gate no matter how many tag/content points it accumulates. +const tagOnlyService: ServiceRecord = { + slug: "crisis-line", + title: "Crisis Line", + tags: ["adult", "treatment"], +}; + +describe("extractKeywordTerms", () => { + it("normalizes, strips stop words, and dedupes", () => { + expect(extractKeywordTerms("What is the max dose of clozapine?")).toEqual(["what", "max", "dose", "clozapine"]); + expect(extractKeywordTerms("dose dose DOSE")).toEqual(["dose"]); + expect(extractKeywordTerms("the of and to a is")).toEqual([]); + }); + + it("caps terms and keeps the legacy 7-term keyword query behavior", () => { + const long = Array.from({ length: 15 }, (_, index) => `token${index}`).join(" "); + expect(extractKeywordTerms(long)).toHaveLength(12); + expect(keywordQueryFromNaturalLanguage(long).split(" ")).toHaveLength(7); + }); +}); + +describe("buildCrossModeLinks", () => { + it("links a full question to the named medication", () => { + const links = buildCrossModeLinks("what is the max dose of clozapine", { medications }); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + modeId: "prescribing", + slug: "clozapine", + detailHref: "/medications/clozapine", + modeSearchQuery: "Clozapine", + }); + expect(links[0]!.modeLabel).toBe("Medication"); + expect(links[0]!.matchReason).toContain("name"); + }); + + it("returns nothing for question filler that only content-matches records", () => { + expect(buildCrossModeLinks("what is the maximum dose", { medications, differentials })).toEqual([]); + }); + + it("links services on title matches and rejects tag-only matches", () => { + const links = buildCrossModeLinks("how do I refer to the adult home treatment team", { + services: [homeTreatmentTeam, tagOnlyService], + }); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + modeId: "services", + slug: "adult-home-treatment-team", + detailHref: "/services/adult-home-treatment-team", + subtitle: "Intensive home-based acute care", + }); + expect(links[0]!.badges).toEqual([{ label: "Acute", tone: "info" }]); + expect(links[0]!.modeSearchHref).toContain("/services?"); + expect(links[0]!.modeSearchHref).toContain("run=1"); + }); + + it("links differentials via alias expansion", () => { + const links = buildCrossModeLinks("how do I manage an acutely psychotic patient", { differentials }); + expect(links).toHaveLength(1); + expect(links[0]!.modeId).toBe("differentials"); + expect(links[0]!.title.toLowerCase()).toMatch(/psychosis|psychotic/); + expect(links[0]!.detailHref).toMatch(/^\/differentials\/(diagnoses|presentations)\//); + }); + + it("does not surface differentials for queries that only name a medication", () => { + const links = buildCrossModeLinks("acamprosate renal dosing", { medications, differentials }); + expect(links.length).toBeGreaterThan(0); + expect(links.every((link) => link.modeId === "prescribing")).toBe(true); + }); + + it("caps per-mode and total results", () => { + const sleepClinic = (slug: string, title: string): ServiceRecord => ({ slug, title }); + const services = [ + sleepClinic("sleep-clinic-north", "Sleep Clinic North"), + sleepClinic("sleep-clinic-south", "Sleep Clinic South"), + sleepClinic("sleep-clinic-east", "Sleep Clinic East"), + ]; + const forms = [ + sleepClinic("sleep-referral-form", "Sleep Clinic Referral"), + sleepClinic("sleep-review-form", "Sleep Clinic Review"), + sleepClinic("sleep-audit-form", "Sleep Clinic Audit"), + ]; + + const links = buildCrossModeLinks("sleep clinic", { services, forms }); + expect(links).toHaveLength(4); + expect(links.filter((link) => link.modeId === "services")).toHaveLength(2); + expect(links.filter((link) => link.modeId === "forms")).toHaveLength(2); + + const capped = buildCrossModeLinks("sleep clinic", { services, forms }, { maxTotal: 3 }); + expect(capped).toHaveLength(3); + }); + + it("dedupes a slug shared between the services and forms registries", () => { + const shared: ServiceRecord = { slug: "shared-pathway", title: "Shared Pathway" }; + const links = buildCrossModeLinks("shared pathway", { services: [shared], forms: [shared] }); + expect(links).toHaveLength(1); + expect(links[0]!.modeId).toBe("services"); + }); + + it("returns nothing for empty or stop-word-only queries and empty catalogs", () => { + expect(buildCrossModeLinks("", { medications })).toEqual([]); + expect(buildCrossModeLinks("the of and", { medications })).toEqual([]); + expect(buildCrossModeLinks("clozapine dose", {})).toEqual([]); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 98b23c05e8..de0edba74e 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1354,6 +1354,31 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); + test("answer results surface cross-mode quick links", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await mockDemoApi(page); + const question = "What is the maximum dose of clozapine?"; + await page.goto(`/?mode=answer&q=${encodeURIComponent(question)}&run=1`, { + waitUntil: "domcontentloaded", + }); + await expect(page.getByTestId("plain-answer-response")).toBeVisible({ timeout: uiAssertionTimeoutMs }); + + const strip = page.getByTestId("cross-mode-links"); + await expect(strip).toBeVisible({ timeout: 15_000 }); + // Close the composer command palette if it opened over the results. + await page.keyboard.press("Escape"); + await expect(strip.getByText("Medication", { exact: true })).toBeVisible(); + await expect( + strip.getByRole("button", { name: "Search Clozapine in Medication" }), + ).toBeVisible(); + + const medicationLink = strip.getByRole("link", { name: "Clozapine", exact: true }); + await expect(medicationLink).toHaveAttribute("href", "/medications/clozapine"); + await medicationLink.click(); + await expect(page).toHaveURL(/\/medications\/clozapine/, { timeout: 15_000 }); + await expectNoPageHorizontalOverflow(page); + }); + test("answer mode keeps prior turns visible for follow-up questions", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoApi(page); From 954daa25ab17186bf33c634fa2a557b004b8cfd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 11:06:10 +0000 Subject: [PATCH 2/6] Apply prettier formatting to cross-mode link files Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nt7UBgqg82NEPinWUZ6PXF --- src/components/clinical-dashboard/cross-mode-links.tsx | 10 ++++++++-- src/lib/cross-mode-links.ts | 7 ++++--- tests/ui-smoke.spec.ts | 4 +--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/clinical-dashboard/cross-mode-links.tsx b/src/components/clinical-dashboard/cross-mode-links.tsx index c2f4e806ec..107e325ea4 100644 --- a/src/components/clinical-dashboard/cross-mode-links.tsx +++ b/src/components/clinical-dashboard/cross-mode-links.tsx @@ -33,7 +33,11 @@ export function CrossModeLinksStrip({ if (links.length === 0) return null; return ( -
+

Also in your library

{links.map((link) => { @@ -50,7 +54,9 @@ export function CrossModeLinksStrip({ > {link.title} - {link.subtitle ?

{link.subtitle}

: null} + {link.subtitle ? ( +

{link.subtitle}

+ ) : null} {link.badges.length > 0 && (
{link.badges.map((badge) => ( diff --git a/src/lib/cross-mode-links.ts b/src/lib/cross-mode-links.ts index 8e4cae506f..55fb8ef41b 100644 --- a/src/lib/cross-mode-links.ts +++ b/src/lib/cross-mode-links.ts @@ -93,8 +93,7 @@ function medicationLinks(query: string, records: MedicationRecord[]): CrossModeL return rankMedicationRecords(records, query, RANKER_CANDIDATE_LIMIT) .filter( (match) => - match.score >= MEDICATION_MIN_SCORE && - (match.reasons.includes("name") || match.reasons.includes("exact name")), + match.score >= MEDICATION_MIN_SCORE && (match.reasons.includes("name") || match.reasons.includes("exact name")), ) .map((match) => ({ ...crossModeLinkBase("prescribing", match.medication.name), @@ -178,7 +177,9 @@ function differentialLinks(terms: string[], catalog: CrossModeDifferentialCatalo }); } - return candidates.sort((left, right) => right.score - left.score || left.title.localeCompare(right.title)).slice(0, 1); + return candidates + .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title)) + .slice(0, 1); } export function buildCrossModeLinks( diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index de0edba74e..42dec3433e 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1368,9 +1368,7 @@ test.describe("Clinical KB UI smoke coverage", () => { // Close the composer command palette if it opened over the results. await page.keyboard.press("Escape"); await expect(strip.getByText("Medication", { exact: true })).toBeVisible(); - await expect( - strip.getByRole("button", { name: "Search Clozapine in Medication" }), - ).toBeVisible(); + await expect(strip.getByRole("button", { name: "Search Clozapine in Medication" })).toBeVisible(); const medicationLink = strip.getByRole("link", { name: "Clozapine", exact: true }); await expect(medicationLink).toHaveAttribute("href", "/medications/clozapine"); From 8d8d376fd6dc9f54d8826c80556398a47892e2b2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Jul 2026 12:41:11 +0000 Subject: [PATCH 3/6] Fix cross-mode links dropping after second entity-free follow-up Walk all prior answer turns (newest to oldest) when the current query yields no cross-mode matches, instead of only checking the immediately preceding turn. --- src/components/ClinicalDashboard.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 003947efce..2b44746728 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -1613,9 +1613,12 @@ export function ClinicalDashboard({ const links = buildCrossModeLinks(latestAnswerQuery, catalogs); if (links.length > 0) return links; // Follow-ups often drop the entity name ("what about renal impairment?"); - // fall back to the previous turn's question so the entity's card persists. - const priorQuery = priorAnswerTurns.at(-1)?.query; - return priorQuery ? buildCrossModeLinks(priorQuery, catalogs) : links; + // walk older turns so the entity's card persists across multi-hop threads. + for (let i = priorAnswerTurns.length - 1; i >= 0; i -= 1) { + const priorLinks = buildCrossModeLinks(priorAnswerTurns[i]!.query, catalogs); + if (priorLinks.length > 0) return priorLinks; + } + return links; }, [ answerCrossLinksActive, latestAnswerQuery, From 5ce499bacaffafbf981e94f0616468cfd6dfc6c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 12:50:59 +0000 Subject: [PATCH 4/6] Harden cross-mode links: thread-wide fallback and word-boundary entity gates Resolve the Bugbot finding by walking the whole answer thread (newest to oldest) for the most recent turn that names an entity, via a pure buildCrossModeLinksForThread helper. Add word-boundary matching to the entity gates so query words hiding inside names ("renal" in "adrenaline") no longer surface junk cards; regression tests cover both. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nt7UBgqg82NEPinWUZ6PXF --- src/components/ClinicalDashboard.tsx | 14 ++----- src/lib/cross-mode-links.ts | 59 +++++++++++++++++++++++----- tests/cross-mode-links.test.ts | 19 ++++++++- 3 files changed, 71 insertions(+), 21 deletions(-) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 2b44746728..efb512a92f 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -162,7 +162,7 @@ import { import { documentsSearchHref } from "@/lib/document-flow-routes"; import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; -import { buildCrossModeLinks, type CrossModeDifferentialCatalog } from "@/lib/cross-mode-links"; +import { buildCrossModeLinksForThread, type CrossModeDifferentialCatalog } from "@/lib/cross-mode-links"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { @@ -1610,15 +1610,9 @@ export function ClinicalDashboard({ forms: crossLinkForms.records, differentials: crossLinkDifferentials ?? undefined, }; - const links = buildCrossModeLinks(latestAnswerQuery, catalogs); - if (links.length > 0) return links; - // Follow-ups often drop the entity name ("what about renal impairment?"); - // walk older turns so the entity's card persists across multi-hop threads. - for (let i = priorAnswerTurns.length - 1; i >= 0; i -= 1) { - const priorLinks = buildCrossModeLinks(priorAnswerTurns[i]!.query, catalogs); - if (priorLinks.length > 0) return priorLinks; - } - return links; + // The thread helper walks back from the latest question until a turn + // names an entity, so cards survive consecutive entity-free follow-ups. + return buildCrossModeLinksForThread([...priorAnswerTurns.map((turn) => turn.query), latestAnswerQuery], catalogs); }, [ answerCrossLinksActive, latestAnswerQuery, diff --git a/src/lib/cross-mode-links.ts b/src/lib/cross-mode-links.ts index 55fb8ef41b..7da1b82ce6 100644 --- a/src/lib/cross-mode-links.ts +++ b/src/lib/cross-mode-links.ts @@ -89,11 +89,27 @@ function serviceChipBadges(record: ServiceRecord): CrossModeLinkBadge[] { return badges; } -function medicationLinks(query: string, records: MedicationRecord[]): CrossModeLink[] { +// The rankers match name/title terms by substring, which lets query words hide +// inside entity names ("renal" inside "adrenaline"). A term only counts as +// naming an entity when it aligns with a word boundary; prefixes are accepted +// for longer terms so plural/possessive drift still matches. +function hasWordBoundaryMatch(value: string, terms: string[]) { + const words = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(" ") + .filter(Boolean); + return terms.some((term) => words.some((word) => word === term || (term.length >= 5 && word.startsWith(term)))); +} + +function medicationLinks(query: string, terms: string[], records: MedicationRecord[]): CrossModeLink[] { return rankMedicationRecords(records, query, RANKER_CANDIDATE_LIMIT) .filter( (match) => - match.score >= MEDICATION_MIN_SCORE && (match.reasons.includes("name") || match.reasons.includes("exact name")), + match.score >= MEDICATION_MIN_SCORE && + (match.reasons.includes("name") || match.reasons.includes("exact name")) && + hasWordBoundaryMatch(`${match.medication.name} ${match.medication.slug}`, terms), ) .map((match) => ({ ...crossModeLinkBase("prescribing", match.medication.name), @@ -109,11 +125,17 @@ function medicationLinks(query: string, records: MedicationRecord[]): CrossModeL function registryLinks( modeId: Extract, query: string, + terms: string[], records: ServiceRecord[], ): CrossModeLink[] { const ranker = modeId === "services" ? rankServiceRecords : rankFormRecords; return ranker(records, query, RANKER_CANDIDATE_LIMIT) - .filter((match) => match.score >= SERVICE_MIN_SCORE && match.reasons.includes("title")) + .filter( + (match) => + match.score >= SERVICE_MIN_SCORE && + match.reasons.includes("title") && + hasWordBoundaryMatch(`${match.service.title} ${match.service.slug}`, terms), + ) .map((match) => ({ ...crossModeLinkBase(modeId, match.service.title), slug: match.service.slug, @@ -126,11 +148,10 @@ function registryLinks( } function differentialTitleScore(title: string, terms: string[], aliasDerived: Set) { - const normalizedTitle = title.toLowerCase(); - // Bare `includes()` on short tokens invites substring junk, so a matching - // term must be at least 4 chars unless it came from a curated alias. + // Word-boundary matching keeps substring junk out; a matching term must + // also be at least 4 chars unless it came from a curated alias. const matches = terms.filter( - (term) => (term.length >= 4 || aliasDerived.has(term)) && normalizedTitle.includes(term), + (term) => (term.length >= 4 || aliasDerived.has(term)) && hasWordBoundaryMatch(title, [term]), ); return matches.length * DIFFERENTIAL_TITLE_TERM_SCORE; } @@ -182,6 +203,24 @@ function differentialLinks(terms: string[], catalog: CrossModeDifferentialCatalo .slice(0, 1); } +// Follow-ups often drop the entity name ("what about renal impairment?"), so +// an answer thread resolves links from its newest turn that names an entity — +// walking all the way back, not just one turn, keeps the entity's card alive +// through consecutive entity-free follow-ups. Queries are ordered oldest first. +export function buildCrossModeLinksForThread( + queries: Array, + catalogs: CrossModeCatalogs, + options: CrossModeLinkOptions = {}, +): CrossModeLink[] { + for (let index = queries.length - 1; index >= 0; index -= 1) { + const query = queries[index]?.trim(); + if (!query) continue; + const links = buildCrossModeLinks(query, catalogs, options); + if (links.length > 0) return links; + } + return []; +} + export function buildCrossModeLinks( query: string, catalogs: CrossModeCatalogs, @@ -195,9 +234,9 @@ export function buildCrossModeLinks( const keywordQuery = terms.join(" "); const candidates = [ - ...medicationLinks(keywordQuery, catalogs.medications ?? []), - ...registryLinks("services", keywordQuery, catalogs.services ?? []), - ...registryLinks("forms", keywordQuery, catalogs.forms ?? []), + ...medicationLinks(keywordQuery, terms, catalogs.medications ?? []), + ...registryLinks("services", keywordQuery, terms, catalogs.services ?? []), + ...registryLinks("forms", keywordQuery, terms, catalogs.forms ?? []), ...(catalogs.differentials ? differentialLinks(terms, catalogs.differentials) : []), ]; diff --git a/tests/cross-mode-links.test.ts b/tests/cross-mode-links.test.ts index 7f9405e9bf..cc79b5c871 100644 --- a/tests/cross-mode-links.test.ts +++ b/tests/cross-mode-links.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { crossModeDifferentialCatalog } from "@/lib/cross-mode-differentials"; -import { buildCrossModeLinks } from "@/lib/cross-mode-links"; +import { buildCrossModeLinks, buildCrossModeLinksForThread } from "@/lib/cross-mode-links"; import { extractKeywordTerms, keywordQueryFromNaturalLanguage } from "@/lib/keyword-query"; import { defaultMedicationRecords } from "@/lib/medication-fixtures"; import type { ServiceRecord } from "@/lib/services"; @@ -116,6 +116,23 @@ describe("buildCrossModeLinks", () => { expect(links[0]!.modeId).toBe("services"); }); + it("keeps entity links alive across multiple entity-free follow-up turns", () => { + const thread = ["what is the max dose of clozapine", "what about renal impairment", "and in elderly patients"]; + const links = buildCrossModeLinksForThread(thread, { medications }); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ modeId: "prescribing", slug: "clozapine" }); + }); + + it("prefers the newest turn that names an entity", () => { + const thread = ["what is the max dose of clozapine", "tell me about acamprosate"]; + const links = buildCrossModeLinksForThread(thread, { medications }); + expect(links).toHaveLength(1); + expect(links[0]!.slug).toBe("acamprosate"); + + expect(buildCrossModeLinksForThread([], { medications })).toEqual([]); + expect(buildCrossModeLinksForThread(["what about renal impairment", null], { medications })).toEqual([]); + }); + it("returns nothing for empty or stop-word-only queries and empty catalogs", () => { expect(buildCrossModeLinks("", { medications })).toEqual([]); expect(buildCrossModeLinks("the of and", { medications })).toEqual([]); From 195270e83991c3c68fb8ce6e7843f40d7da1e408 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 13:43:26 +0000 Subject: [PATCH 5/6] Add cross-mode link follow-ups: precision, payload, telemetry, docs-mode strip - Word-boundary field matching in rankCatalogRecords so query terms cannot hide inside entity names ('renal' inside 'adrenaline'); prefix matching preserves search-as-you-type. Regression-tested in tests/medications.test.ts. - fields=index slim mode on /api/medications (~30 KB identity slice instead of the ~3.4 MB catalog) used by the cross-mode links surface. - Cross-mode click telemetry: /api/search/interaction accepts a crossMode target and stores a privacy-hardened miss row with the mode/slug in metadata; strip links and search actions log through it. - CrossModeLinksSection: self-contained catalogs+matching+strip component; mounted under answers (thread-aware), dashboard documents results, and the /documents/search command centre. - Answer command-surface crossModes now lists services and forms, matching the strip's coverage. - docs/process-hardening.md records the verify:release debt for this workstream per repo convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Nt7UBgqg82NEPinWUZ6PXF --- docs/process-hardening.md | 6 ++ src/app/api/medications/route.ts | 43 ++++++++-- src/app/api/search/interaction/route.ts | 59 ++++++++++++-- src/components/ClinicalDashboard.tsx | 54 +++---------- .../clinical-dashboard/cross-mode-links.tsx | 80 ++++++++++++++++++- .../clinical-dashboard/source-actions.tsx | 14 ++++ .../use-medication-catalog.ts | 11 ++- .../master-document-flow-mockups.tsx | 5 ++ src/lib/catalog-search.ts | 7 +- src/lib/keyword-query.ts | 13 +++ src/lib/search-command-surface.ts | 4 +- tests/medications-route.test.ts | 20 +++++ tests/medications.test.ts | 9 +++ tests/search-interaction-route.test.ts | 56 +++++++++++++ 14 files changed, 314 insertions(+), 67 deletions(-) diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 89cea5bab0..2776610ec0 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -169,3 +169,9 @@ All approved render-surface modules are extracted. `ClinicalDashboard.tsx` went - **Eval debt (blocking merge, not development):** `npm run eval:retrieval:quality` (23/23) and `eval:quality --rag-only` (`unsupported_correct_rate` 1.0) could NOT be run in the authoring environment (no live keys) — they MUST be run before merge per the standing gate above, with special attention to the weak-match OR-augmentation (flag off restores relax-on-empty exactly) and the retrieval-selection tiebreak (tie-only by construction). - **UI verification run:** new `tests/ui-universal-search.spec.ts` (grouped typeahead renders, item selection navigates, Enter still runs the mode search — universal endpoint mocked), full `ui-tools`/`ui-tools-task-directory` (40/40) and `ui-smoke`/`ui-overlap` suites against a live dev server in demo mode, plus a live curl of `/api/search/universal` (grouped payload, domain filter, 400 on short query). - **Known limitation:** the typeahead spec mocks the universal endpoint; an end-to-end spec against live seeded registries needs the owner-auth Playwright project (E2E_USER_* keys). + +## Cross-mode answer links workstream — verification state (2026-07-06) + +- **Shipped on `claude/search-cross-mode-links-qscj1n`:** post-answer "Also in your library" strip (`src/lib/cross-mode-links.ts` + `CrossModeLinksStrip`), thread-wide entity fallback, word-boundary field matching in `rankCatalogRecords` (substring hits like "renal" inside "adrenaline" no longer count as name/title matches), `fields=index` slim mode on `/api/medications`, cross-mode click telemetry via `/api/search/interaction` (`crossMode` target, `metadata.interaction: "cross_mode_link_open"`), the same strip on documents-mode results, and answer `crossModes` command-surface parity. +- **Verification debt:** `npm run verify:release` (and its governance/eval gates) has not been run for this workstream — the authoring environment has no live Supabase/OpenAI keys. Run it from a secrets-equipped environment after merge; the cross-mode surface itself is additive/navigational, so `verify:cheap` + `verify:ui` are the load-bearing local gates. +- **Telemetry note:** cross-mode clicks write `rag_query_misses` rows with `clicked_document_id: null` and the target mode/slug in `metadata`; retrieval-quality reviews that aggregate misses by document should filter on `metadata.interaction`. diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 365b3090f4..4711cafb05 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -16,7 +16,12 @@ import { rowToMedicationRecord, type MedicationRecordRow, } from "@/lib/medication-records"; -import { medicationToSearchResult, rankMedicationRecords, type MedicationSearchMatch } from "@/lib/medications"; +import { + medicationToSearchResult, + rankMedicationRecords, + type MedicationRecord, + type MedicationSearchMatch, +} from "@/lib/medications"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -34,8 +39,29 @@ const medicationListQuerySchema = z.object({ .optional() .transform((value) => (value ? value : undefined)), limit: queryInteger({ fallback: 50, min: 1, max: 100 }), + fields: z.enum(["index"]).optional(), }); +// `fields=index` strips the heavy per-record content (stats/sections/quick are +// ~99% of the ~3.4 MB catalog) for callers that only need identity-level +// ranking, e.g. the answer surface's cross-mode links. The records keep the +// full MedicationRecord shape so rankers and badge helpers work unchanged. +function toIndexRecords(records: MedicationRecord[]): MedicationRecord[] { + return records.map((record) => ({ + slug: record.slug, + name: record.name, + class: record.class, + subclass: record.subclass, + category: record.category, + accent: record.accent, + tag: record.tag, + schedule: record.schedule, + stats: [], + sections: [], + quick: [], + })); +} + function medicationResponse(payload: Record) { return NextResponse.json(payload, { headers: { "Cache-Control": "private, no-store" } }); } @@ -49,8 +75,8 @@ function matchesPayload(matches: MedicationSearchMatch[]) { })); } -function publicMedicationPayload(q: string | undefined, limit: number) { - const records = defaultMedicationRecords(); +function publicMedicationPayload(q: string | undefined, limit: number, fields?: "index") { + const records = fields === "index" ? toIndexRecords(defaultMedicationRecords()) : defaultMedicationRecords(); const governance = Object.fromEntries( records.map((record) => [ record.slug, @@ -71,18 +97,18 @@ function publicMedicationPayload(q: string | undefined, limit: number) { export async function GET(request: Request) { try { - const { q, limit } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); + const { q, limit, fields } = parseRequestQuery(request, medicationListQuerySchema, "Invalid medication query."); if (isDemoMode() || isLocalNoAuthMode()) { return medicationResponse({ - ...publicMedicationPayload(q, limit), + ...publicMedicationPayload(q, limit, fields), demoMode: true, }); } if (!shouldResolvePublicCatalogAccess(request)) { return medicationResponse({ - ...publicMedicationPayload(q, limit), + ...publicMedicationPayload(q, limit, fields), publicAccess: true, }); } @@ -102,13 +128,14 @@ export async function GET(request: Request) { if (!access.ownerId) { return medicationResponse({ - ...publicMedicationPayload(q, limit), + ...publicMedicationPayload(q, limit, fields), publicAccess: true, }); } const rows = await fetchOwnerMedicationRowsWithSeed(supabase, access.ownerId, MEDICATION_MAX_RECORDS); - const records = rows.map(rowToMedicationRecord); + const fullRecords = rows.map(rowToMedicationRecord); + const records = fields === "index" ? toIndexRecords(fullRecords) : fullRecords; const governanceBySlug = Object.fromEntries(rows.map((row) => [row.slug, rowGovernance(row)])); return medicationResponse({ diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index b05736ffbf..9e6d7bc28d 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -15,15 +15,26 @@ import { parseJsonBody } from "@/lib/validation/body"; export const runtime = "nodejs"; -const interactionSchema = z.object({ - query: z.string().trim().min(1).max(2000), - documentId: z.string().uuid(), - chunkId: z.string().uuid().optional(), - fileName: z.string().trim().max(240).optional(), +const crossModeTargetSchema = z.object({ + mode: z.enum(["prescribing", "services", "forms", "differentials"]), + slug: z.string().trim().min(1).max(160), title: z.string().trim().max(240).optional(), - queryClass: z.string().trim().max(80).optional(), }); +const interactionSchema = z + .object({ + query: z.string().trim().min(1).max(2000), + documentId: z.string().uuid().optional(), + chunkId: z.string().uuid().optional(), + fileName: z.string().trim().max(240).optional(), + title: z.string().trim().max(240).optional(), + queryClass: z.string().trim().max(80).optional(), + crossMode: crossModeTargetSchema.optional(), + }) + .refine((body) => Boolean(body.documentId || body.crossMode), { + message: "Either documentId or a crossMode target is required.", + }); + function safeTelemetryText(value: string | undefined) { const cleaned = value ?.replace(/[\u0000-\u001f\u007f]+/g, " ") @@ -74,6 +85,42 @@ export async function POST(request: Request) { // Carry the authenticated owner through so the miss row is attributable and // owner-cleanable instead of being orphaned with owner_id: null (RET-H4). const user = await serverAuth.requireAuthenticatedUser(request, supabase); + + // Cross-mode link clicks reference registry/medication slugs, not owned + // documents; store the same privacy-hardened miss row with the target in + // metadata so retrieval-quality reviews can see which modes get used. + if (!body.documentId) { + const target = body.crossMode!; + const { error: insertError } = await supabase.from("rag_query_misses").insert({ + owner_id: user.id, + query: queryTextForStorage(body.query), + normalized_query: normalizedQueryTextForStorage(body.query), + query_class: body.queryClass ?? null, + clicked_document_id: null, + clicked_chunk_id: null, + top_files: [], + top_chunk_ids: [], + miss_reason: "clicked_result", + candidate_aliases: queryDerivedTokensForStorage(normalizedClinicalSearchTokens(body.query).slice(0, 10)), + candidate_labels: [ + { + label: safeTelemetryText(target.title) ?? target.slug, + label_type: "cross_mode_target", + document_id: null, + confidence: 1, + }, + ], + metadata: { + interaction: "cross_mode_link_open", + cross_mode_target: target.mode, + cross_mode_slug: target.slug, + ...queryPrivacyMetadata(body.query), + }, + }); + if (insertError) throw new Error(insertError.message); + return NextResponse.json({ ok: true }); + } + const hasOwnedDocument = await ownedDocumentExists({ supabase, ownerId: user.id, documentId: body.documentId }); const hasOwnedChunk = hasOwnedDocument ? await ownedChunkExists({ supabase, documentId: body.documentId, chunkId: body.chunkId }) diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx index 25177f77c3..49bd4d6ac6 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -63,9 +63,8 @@ import { useAuthSession } from "@/lib/supabase/client"; import { Sheet } from "@/components/ui/sheet"; import { AccountSetupDialog } from "@/components/clinical-dashboard/account-setup-dialog"; import { StagedAnswerResultSurface } from "@/components/clinical-dashboard/answer-result-surface"; -import { CrossModeLinksStrip } from "@/components/clinical-dashboard/cross-mode-links"; +import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; -import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; import { useTheme } from "@/components/clinical-dashboard/use-theme"; @@ -162,7 +161,6 @@ import { import { documentsSearchHref } from "@/lib/document-flow-routes"; import { rankFormRecords } from "@/lib/forms"; import { rankServiceRecords } from "@/lib/services"; -import { buildCrossModeLinksForThread, type CrossModeDifferentialCatalog } from "@/lib/cross-mode-links"; import { useRegistryRecords } from "@/lib/use-registry-records"; import { buildAnswerFollowUpQuery, buildAnswerFollowUpSuggestions } from "@/lib/answer-follow-up"; import { @@ -1608,46 +1606,6 @@ export function ClinicalDashboard({ [searchMode, formSearchMatches, serviceSearchMatches], ); const recordSearchMode = searchMode === "forms" ? "forms" : "services"; - // Cross-mode quick links rendered under answers. Catalogs come from the - // same owner-scoped APIs the modes themselves use (fixtures in demo mode), - // and nothing is fetched until the first answer lands. - const answerCrossLinksActive = activeModeResultKind === "answer" && answer !== null; - const crossLinkServices = useRegistryRecords("service", { enabled: answerCrossLinksActive }); - const crossLinkForms = useRegistryRecords("form", { enabled: answerCrossLinksActive }); - const crossLinkMedications = useMedicationCatalog(undefined, { enabled: answerCrossLinksActive }); - const [crossLinkDifferentials, setCrossLinkDifferentials] = useState(null); - useEffect(() => { - // Dynamic import keeps the 1.2 MB differentials snapshot out of the - // dashboard bundle; the catalog is loaded once per session. - if (!answerCrossLinksActive || crossLinkDifferentials) return; - let cancelled = false; - import("@/lib/cross-mode-differentials").then((module) => { - if (!cancelled) setCrossLinkDifferentials(module.crossModeDifferentialCatalog()); - }); - return () => { - cancelled = true; - }; - }, [answerCrossLinksActive, crossLinkDifferentials]); - const crossModeLinks = useMemo(() => { - if (!answerCrossLinksActive || !latestAnswerQuery) return []; - const catalogs = { - medications: crossLinkMedications.data?.records ?? [], - services: crossLinkServices.records, - forms: crossLinkForms.records, - differentials: crossLinkDifferentials ?? undefined, - }; - // The thread helper walks back from the latest question until a turn - // names an entity, so cards survive consecutive entity-free follow-ups. - return buildCrossModeLinksForThread([...priorAnswerTurns.map((turn) => turn.query), latestAnswerQuery], catalogs); - }, [ - answerCrossLinksActive, - latestAnswerQuery, - priorAnswerTurns, - crossLinkMedications.data, - crossLinkServices.records, - crossLinkForms.records, - crossLinkDifferentials, - ]); // The thread mirror ref must never outlive the answer it describes: every // reset path nulls `answer`, so clearing here covers them all (mode // switches, new chat, differentials/services clears) without each caller @@ -4072,6 +4030,9 @@ export function ClinicalDashboard({ ) : ( <> + {searchMode === "documents" && modeSearchSubmitted && ( + + )} 0 && ( - + {activeModeResultKind === "answer" && answer && ( + turn.query), latestAnswerQuery]} + onModeSearch={crossModeSearch} + /> )} {activeModeResultKind === "answer" && answer && ( ; + enabled?: boolean; + // Defaults to navigating to the target mode with the search pre-run. + onModeSearch?: (mode: AppModeId, query: string) => void; +}) { + const router = useRouter(); + const services = useRegistryRecords("service", { enabled }); + const forms = useRegistryRecords("form", { enabled }); + // fields=index keeps this to the ~30 KB identity slice of the catalog. + const medications = useMedicationCatalog(undefined, { enabled, fields: "index" }); + const [differentials, setDifferentials] = useState(null); + useEffect(() => { + // Dynamic import keeps the 1.2 MB differentials snapshot out of the + // dashboard bundle; the catalog is loaded once per session. + if (!enabled || differentials) return; + let cancelled = false; + import("@/lib/cross-mode-differentials").then((module) => { + if (!cancelled) setDifferentials(module.crossModeDifferentialCatalog()); + }); + return () => { + cancelled = true; + }; + }, [enabled, differentials]); + + // Memo on the thread's contents, not the (per-render) array identity. + const queriesKey = queries.filter((value): value is string => Boolean(value?.trim())).join("\u0000"); + const links = useMemo(() => { + if (!enabled || !queriesKey) return []; + return buildCrossModeLinksForThread(queriesKey.split("\u0000"), { + medications: medications.data?.records ?? [], + services: services.records, + forms: forms.records, + differentials: differentials ?? undefined, + }); + }, [enabled, queriesKey, medications.data, services.records, forms.records, differentials]); + + if (links.length === 0) return null; + + const telemetryQuery = queriesKey.split("\u0000").at(-1) ?? ""; + const handleModeSearch = + onModeSearch ?? + ((mode: AppModeId, query: string) => { + router.push(appModeHomeHref(mode, { query, focus: true, run: true })); + }); + + return ; +} + export function CrossModeLinksStrip({ links, onModeSearch, + query = "", }: { links: CrossModeLink[]; onModeSearch: (mode: AppModeId, query: string) => void; + // The search text that produced the links; used only for click telemetry. + query?: string; }) { if (links.length === 0) return null; @@ -50,6 +120,7 @@ export function CrossModeLinksStrip({
logCrossModeLinkOpen(query, link)} className="inline-flex min-h-[44px] items-center text-sm font-semibold text-[color:var(--text)] transition hover:text-[color:var(--clinical-accent)]" > {link.title} @@ -76,7 +147,10 @@ export function CrossModeLinksStrip({ {link.modeLabel}
+
+ +
+
Document diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index 20ad2c1436..5328a3c01a 100644 --- a/src/lib/catalog-search.ts +++ b/src/lib/catalog-search.ts @@ -5,6 +5,8 @@ // rankCatalogRecords with their historical field weights; the wrapper owns its reason // labels and match shape so existing API/UI contracts are unchanged. +import { matchesTermAtWordBoundary } from "@/lib/keyword-query"; + // Canonical normalizer (the medications implementation — the superset of the retired // services/forms variants: NFKD + diacritic strip, and `+ . / -` survive so dose strings // ("5+5", "0.5mg", "IM/PO") and hyphenated clinical terms stay searchable). @@ -102,7 +104,10 @@ export function rankCatalogRecords( for (const field of options.fields) { const haystack = field.text(record); if (!haystack) continue; - const matched = terms.filter((term) => haystack.includes(term)).length; + // Fields are the high-weight name/title/tag signals, so a term must + // align with a word boundary — substring hits ("renal" inside + // "adrenaline") stay confined to the low-weight content haystack. + const matched = terms.filter((term) => matchesTermAtWordBoundary(haystack, term)).length; if (!matched) continue; fields[field.id] = matched; score += matched * field.weight; diff --git a/src/lib/keyword-query.ts b/src/lib/keyword-query.ts index e8edae7bd8..ac22366516 100644 --- a/src/lib/keyword-query.ts +++ b/src/lib/keyword-query.ts @@ -89,3 +89,16 @@ export function extractKeywordTerms(query: string, options: { maxTerms?: number export function keywordQueryFromNaturalLanguage(query: string) { return extractKeywordTerms(query, { maxTerms: 7 }).join(" "); } + +// A query term only counts against a name/title when it aligns with a word +// boundary — exact word, or word prefix to keep search-as-you-type working — +// so terms cannot hide inside words ("renal" inside "adrenaline"). Words are +// split on every non-alphanumeric so tokens like "im/po" or "co-codamol" +// match on their parts. +export function matchesTermAtWordBoundary(text: string, term: string) { + if (!term) return false; + return text + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((word) => word === term || word.startsWith(term)); +} diff --git a/src/lib/search-command-surface.ts b/src/lib/search-command-surface.ts index e931aa380a..20556cd406 100644 --- a/src/lib/search-command-surface.ts +++ b/src/lib/search-command-surface.ts @@ -115,7 +115,9 @@ const searchCommandSurfaceByMode: Partial { expect(client.auth.getUser).not.toHaveBeenCalled(); }); + it("serves an identity-only slim catalog for fields=index", async () => { + const client = createSupabaseMock(); + mockRuntime(client, { demoMode: true }); + const { GET } = await import("../src/app/api/medications/route"); + + const response = await GET(request("/api/medications?fields=index")); + const payload = (await response.json()) as { + records: Array<{ slug: string; name: string; stats: unknown[]; sections: unknown[]; quick: unknown[] }>; + }; + + expect(response.status).toBe(200); + const acamprosate = payload.records.find((record) => record.slug === "acamprosate"); + expect(acamprosate?.name).toBe("Acamprosate"); + expect( + payload.records.every( + (record) => record.stats.length === 0 && record.sections.length === 0 && record.quick.length === 0, + ), + ).toBe(true); + }); + it("serves curated public records for unauthenticated list requests outside demo mode", async () => { const client = createSupabaseMock(); mockRuntime(client); diff --git a/tests/medications.test.ts b/tests/medications.test.ts index a9840998c9..8fc0789c15 100644 --- a/tests/medications.test.ts +++ b/tests/medications.test.ts @@ -18,6 +18,15 @@ describe("medications catalogue", () => { expect(matches[0]?.score).toBeGreaterThan(0); }); + it("does not treat mid-word substrings as name matches", () => { + const records = loadMedicationSnapshot(); + // "renal" hides inside "adrenaline"/"noradrenaline"; a name-level hit for + // it would outrank genuinely relevant content matches. + const matches = rankMedicationRecords(records, "renal dose", 10); + const adrenaline = matches.find((match) => match.medication.slug.includes("adrenaline")); + expect(adrenaline?.reasons ?? []).not.toContain("name"); + }); + it("exposes prescribing summary fields for search results", () => { const record = getMedicationRecord("acamprosate"); expect(record).toBeTruthy(); diff --git a/tests/search-interaction-route.test.ts b/tests/search-interaction-route.test.ts index 0c34f0bada..9578464123 100644 --- a/tests/search-interaction-route.test.ts +++ b/tests/search-interaction-route.test.ts @@ -138,6 +138,62 @@ describe("/api/search/interaction", () => { ]); }); + it("stores cross-mode link clicks without a document id", async () => { + const { client, insert } = createClient({ ownsDocument: false, ownsChunk: false }); + vi.doMock("@/lib/env", () => ({ env: { RAG_PERSIST_RAW_QUERY_TEXT: false }, isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => ({ id: userId })), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + })); + const { POST } = await import("../src/app/api/search/interaction/route"); + + const response = await POST( + request({ + query: "clozapine maximum dose", + crossMode: { mode: "prescribing", slug: "clozapine", title: clozapineTitle }, + }), + ); + const payload = insert.mock.calls[0]?.[0] as Record; + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + owner_id: userId, + clicked_document_id: null, + clicked_chunk_id: null, + top_files: [], + top_chunk_ids: [], + miss_reason: "clicked_result", + }); + expect(payload.query).toMatch(/^redacted-query:[a-f0-9]{64}$/); + expect(payload.candidate_labels).toEqual([ + { + label: "Clozapine Monitoring", + label_type: "cross_mode_target", + document_id: null, + confidence: 1, + }, + ]); + expect(payload.metadata).toMatchObject({ + interaction: "cross_mode_link_open", + cross_mode_target: "prescribing", + cross_mode_slug: "clozapine", + }); + // Cross-mode targets are not documents; no ownership lookups should run. + expect(client.from).toHaveBeenCalledWith("rag_query_misses"); + expect(client.from).not.toHaveBeenCalledWith("documents"); + }); + + it("rejects interactions with neither a document id nor a cross-mode target", async () => { + const { POST } = await import("../src/app/api/search/interaction/route"); + + const response = await POST(request({ query: "clozapine monitoring" })); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid interaction request." }); + }); + it("does not persist PHI-capable query text in source-open miss telemetry", async () => { const { client, insert } = createClient({ ownsDocument: true, ownsChunk: true }); vi.doMock("@/lib/env", () => ({ env: { RAG_PERSIST_RAW_QUERY_TEXT: false }, isDemoMode: () => false })); From db2b21a45119fa98abf79a3a160bda38093c1fb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:58:31 +0000 Subject: [PATCH 6/6] Fix: resolve conflict markers in tests/medications.test.ts, keep both tests --- tests/medications.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/medications.test.ts b/tests/medications.test.ts index 2b7e5dd331..a787909694 100644 --- a/tests/medications.test.ts +++ b/tests/medications.test.ts @@ -18,7 +18,6 @@ describe("medications catalogue", () => { expect(matches[0]?.score).toBeGreaterThan(0); }); -<<<<<<< HEAD it("does not treat mid-word substrings as name matches", () => { const records = loadMedicationSnapshot(); // "renal" hides inside "adrenaline"/"noradrenaline"; a name-level hit for @@ -26,13 +25,13 @@ describe("medications catalogue", () => { const matches = rankMedicationRecords(records, "renal dose", 10); const adrenaline = matches.find((match) => match.medication.slug.includes("adrenaline")); expect(adrenaline?.reasons ?? []).not.toContain("name"); -======= + }); + 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"); ->>>>>>> origin/main }); it("exposes prescribing summary fields for search results", () => {