diff --git a/docs/process-hardening.md b/docs/process-hardening.md index 92ea6ab91e..7c3f048743 100644 --- a/docs/process-hardening.md +++ b/docs/process-hardening.md @@ -175,3 +175,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 6a74393d22..f9aa353fda 100644 --- a/src/components/ClinicalDashboard.tsx +++ b/src/components/ClinicalDashboard.tsx @@ -63,6 +63,7 @@ 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 { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { RelatedDocumentsPanel } from "@/components/clinical-dashboard/document-results"; import { AuthPanel } from "@/components/clinical-dashboard/auth-panel"; import { useSidebarCollapsed } from "@/components/clinical-dashboard/use-sidebar-collapsed"; @@ -4044,6 +4045,9 @@ export function ClinicalDashboard({ ) : ( <> + {searchMode === "documents" && modeSearchSubmitted && ( + + )} 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; + + return ( +
+

Also in your library

+
+ {links.map((link) => { + const Icon = appModeIcons[link.modeId]; + return ( +
+ + + +
+ 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} + + {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/source-actions.tsx b/src/components/clinical-dashboard/source-actions.tsx index b3654edae2..ef374e88d2 100644 --- a/src/components/clinical-dashboard/source-actions.tsx +++ b/src/components/clinical-dashboard/source-actions.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ExternalLink, FileText, Filter, Search } from "lucide-react"; import { cn, floatingControl, metadataPill, primaryControl } from "@/components/ui-primitives"; +import type { CrossModeLink } from "@/lib/cross-mode-links"; import type { SearchResult } from "@/lib/types"; export function SourceActionRow({ @@ -79,6 +80,19 @@ export function logSourceOpen(query: string, source: SearchResult) { }).catch(() => undefined); } +export function logCrossModeLinkOpen(query: string, link: Pick) { + if (!query.trim()) return; + void fetch("/api/search/interaction", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query, + crossMode: { mode: link.modeId, slug: link.slug, title: link.title }, + }), + keepalive: true, + }).catch(() => undefined); +} + export function SourcePassageLinks({ heading, sources, diff --git a/src/components/clinical-dashboard/use-medication-catalog.ts b/src/components/clinical-dashboard/use-medication-catalog.ts index dbf19e0ca5..074812a5c6 100644 --- a/src/components/clinical-dashboard/use-medication-catalog.ts +++ b/src/components/clinical-dashboard/use-medication-catalog.ts @@ -42,30 +42,42 @@ async function fetchJson(url: string, headers?: HeadersInit): Promise { return (await response.json()) as T; } -export function useMedicationCatalog(query?: string): AsyncState { +export function useMedicationCatalog( + query?: string, + options: { enabled?: boolean; fields?: "index" } = {}, +): AsyncState { + const enabled = options.enabled ?? true; + const fields = options.fields; const trimmed = query?.trim() ?? ""; // Auth-aware like use-registry-records: without the header an authenticated owner was // silently served the public fixture catalogue instead of their seeded records. const { authorizationHeader } = useAuthSession(); 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"; + const params = new URLSearchParams(); + if (trimmed) params.set("q", trimmed); + if (fields) params.set("fields", fields); + const suffix = params.toString(); + const url = suffix ? `/api/medications?${suffix}` : "/api/medications"; fetchJson(url, authorizationHeader) .then((data) => { if (!cancelled) setState({ data, loading: false, error: null }); @@ -82,7 +94,7 @@ export function useMedicationCatalog(query?: string): AsyncState { cancelled = true; }; - }, [trimmed, authorizationHeader]); + }, [trimmed, enabled, fields, authorizationHeader]); return state; } diff --git a/src/components/master-document-flow-mockups.tsx b/src/components/master-document-flow-mockups.tsx index ec52855cf4..aeba0ddf10 100644 --- a/src/components/master-document-flow-mockups.tsx +++ b/src/components/master-document-flow-mockups.tsx @@ -33,6 +33,7 @@ import { } from "lucide-react"; import { ReactNode, useMemo, useState } from "react"; +import { CrossModeLinksSection } from "@/components/clinical-dashboard/cross-mode-links"; import { cn } from "@/components/ui-primitives"; import { documentEvidenceHref, documentReaderHref, documentsSearchHref } from "@/lib/document-flow-routes"; @@ -757,6 +758,10 @@ export function MasterDocumentSearch() { +
+ +
+
Document diff --git a/src/lib/catalog-search.ts b/src/lib/catalog-search.ts index 1976087461..b06a1daa1c 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). @@ -115,7 +117,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/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..7da1b82ce6 --- /dev/null +++ b/src/lib/cross-mode-links.ts @@ -0,0 +1,273 @@ +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; +} + +// 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")) && + hasWordBoundaryMatch(`${match.medication.name} ${match.medication.slug}`, terms), + ) + .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, + 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") && + hasWordBoundaryMatch(`${match.service.title} ${match.service.slug}`, terms), + ) + .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) { + // 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)) && hasWordBoundaryMatch(title, [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); +} + +// 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, + 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, terms, catalogs.medications ?? []), + ...registryLinks("services", keywordQuery, terms, catalogs.services ?? []), + ...registryLinks("forms", keywordQuery, terms, 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..ac22366516 --- /dev/null +++ b/src/lib/keyword-query.ts @@ -0,0 +1,104 @@ +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(" "); +} + +// 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 { + 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("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([]); + expect(buildCrossModeLinks("clozapine dose", {})).toEqual([]); + }); +}); diff --git a/tests/medications-route.test.ts b/tests/medications-route.test.ts index 87ce72dded..7d923bbd4f 100644 --- a/tests/medications-route.test.ts +++ b/tests/medications-route.test.ts @@ -167,6 +167,26 @@ describe("medications API", () => { 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 8f8a61bff9..a787909694 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("boosts name-prefix matches above content-only matches", () => { const records = loadMedicationSnapshot(); const matches = rankMedicationRecords(records, "sert", 10); 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 })); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 7055c81838..b274ae73b8 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1354,6 +1354,29 @@ 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);