diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 68c6cb9aa..ca66ce943 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1103,6 +1103,60 @@ export function normalizedClinicalSearchTokens(query: string) { .filter((token) => (token.length > 2 || shortClinicalSearchTerms.has(token)) && !textSearchStopWords.has(token)); } +const genericMedicationDoseQueryTokens = new Set([ + "administer", + "administration", + "chart", + "dose", + "dosage", + "dosing", + "drug", + "frequency", + "guidance", + "listed", + "management", + "maximum", + "medication", + "medicine", + "oral", + "intramuscular", + "subcutaneous", + "subcut", + "sublingual", + "route", + "shown", + "table", + "used", + "using", + "usual", + "im", + "po", + "sc", + "sl", +]); + +/** Require dose evidence to carry the medication question's clinical subject. */ +export function medicationDoseQuerySubjectTokens(query: string) { + return normalizedClinicalSearchTokens(query).filter((token) => !genericMedicationDoseQueryTokens.has(token)); +} + +/** Require dose evidence to carry the medication question's clinical subject. */ +export function medicationDoseQueryContext(query: string, result: SearchResult) { + const subjectTokens = medicationDoseQuerySubjectTokens(query); + if (!subjectTokens.length) { + return { hasClinicalSubject: false, matched: true, hitCount: 0, requiredHits: 0 }; + } + const evidenceTokens = new Set(normalizedClinicalSearchTokens(clinicalResultEvidenceHaystack(result))); + const hitCount = subjectTokens.filter((token) => evidenceTokens.has(token)).length; + const requiredHits = Math.min(2, subjectTokens.length); + return { + hasClinicalSubject: true, + matched: hitCount >= requiredHits, + hitCount, + requiredHits, + }; +} + /** Build clinical text search query. */ export function buildClinicalTextSearchQuery(query: string) { const normalizedTokens = normalizedClinicalSearchTokens(query); @@ -1287,6 +1341,15 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se ) ? -0.3 : 0; + const medicationDoseContext = medicationDoseQueryContext(query, result); + const medicationDoseContextBoost = + queryClass === "medication_dose_risk" && medicationDoseContext.hasClinicalSubject && medicationDoseContext.matched + ? 0.26 + : 0; + const medicationDoseContextPenalty = + queryClass === "medication_dose_risk" && medicationDoseContext.hasClinicalSubject && !medicationDoseContext.matched + ? -0.24 + : 0; const protocolBoost = querySignal.intent === "protocol" && /(protocol|process|procedure|workflow|pathway|algorithm)/i.test(haystack) ? 0.06 @@ -1533,12 +1596,14 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se riskFlowchartCanonicalBoost + directAnswerBoost + comparisonCoverageBoost + + medicationDoseContextBoost + sectionDepth + indexUnitBoost + assetBoost; const rawPenalty = titleOnlyDosePenalty + administrativeDoseQueryPenalty + + medicationDoseContextPenalty + coreConceptPenalty + clozapineSpecificPenalty + genericDischargePenalty + diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 3ae154d33..8daaf83fe 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -110,6 +110,7 @@ import { expandClinicalQuery, hasDoseEvidenceSupport, hasStructuredThresholdEvidence, + medicationDoseQueryContext, normalizedClinicalSearchTokens, rankClinicalResults, riskZoneActionPattern, @@ -2866,9 +2867,7 @@ export function evaluateEvidenceCoverageGate( } const hasStructuredThreshold = top.some(hasStructuredThresholdEvidence); - const hasDoseEvidence = top.some(hasDoseEvidenceSupport); const hasDoseAmount = top.some(hasDoseAmountEvidenceForGate); - const hasRoute = top.some(hasRouteEvidenceForGate); const hasVisualUnit = top.some((result) => visualEvidenceUnitTypes.has(result.index_unit?.unit_type ?? "")); const hasDirectTitle = directTitleOrAliasSupport(query, top); @@ -2922,23 +2921,36 @@ export function evaluateEvidenceCoverageGate( } if (queryClass === "medication_dose_risk") { - const asksDoseRoute = - /\b(?:dose|dosage|dosing|route|oral|intramuscular|subcutaneous|subcut|sublingual|\bim\b|\bpo\b|\bsc\b|\bsl\b)\b/i.test( - query, - ); + const asksRoute = + /\b(?:route|oral|intramuscular|subcutaneous|subcut|sublingual|\bim\b|\bpo\b|\bsc\b|\bsl\b)\b/i.test(query); const agitationOk = !/\bagitation|arousal\b/i.test(query) || /\bagitation|arousal\b/i.test(evidenceText); - const accepted = hasDoseEvidence && hasDoseAmount && (!asksDoseRoute || hasRoute) && agitationOk; + const hasContextualDose = top.some( + (result) => + hasDoseEvidenceSupport(result) && + hasDoseAmountEvidenceForGate(result) && + medicationDoseQueryContext(query, result).matched, + ); + const hasContextualRoute = top.some( + (result) => + hasDoseEvidenceSupport(result) && + hasDoseAmountEvidenceForGate(result) && + hasRouteEvidenceForGate(result) && + medicationDoseQueryContext(query, result).matched, + ); + const accepted = hasContextualDose && (!asksRoute || hasContextualRoute) && agitationOk; return { accepted, reason: accepted ? "dose_route_amount_evidence_gate" : !hasDoseAmount ? "missing_dose_amount_evidence" - : !hasRoute && asksDoseRoute - ? "missing_route_evidence" - : !agitationOk - ? "missing_agitation_context" - : "missing_dose_evidence", + : !hasContextualDose + ? "missing_dose_query_context" + : !hasContextualRoute && asksRoute + ? "missing_route_evidence" + : !agitationOk + ? "missing_agitation_context" + : "missing_dose_evidence", strategy: "text_fast_path", sourceImageRequired, sourceImageSatisfied, diff --git a/src/lib/retrieval-selection.ts b/src/lib/retrieval-selection.ts index eaf7bef49..a8f0c93e4 100644 --- a/src/lib/retrieval-selection.ts +++ b/src/lib/retrieval-selection.ts @@ -1,4 +1,5 @@ import { citationFromResult, documentCitationHref } from "@/lib/citations"; +import { medicationDoseQueryContext, medicationDoseQuerySubjectTokens } from "@/lib/clinical-search"; import type { RagQueryClass, RetrievalCandidate, @@ -232,6 +233,7 @@ function signalMatchesText(signal: string, text: string) { } function matchedSignalsForResult(args: { + query: string; intent: RetrievalIntent; result: SearchResult; chunkType: RetrievalChunkType; @@ -259,6 +261,12 @@ function matchedSignalsForResult(args: { if (args.chunkType !== "text") signals.push(args.chunkType); if (args.intent.needsDoseRouteFrequency && hasDoseAmount(text)) signals.push("dose_amount"); if (args.intent.needsDoseRouteFrequency && hasRoute(text)) signals.push("route"); + if ( + args.intent.requiredTermSignals.includes("clinical_subject") && + medicationDoseQueryContext(args.query, args.result).matched + ) { + signals.push("clinical_subject"); + } if (args.intent.needsPatientEducation && signalMatchesText("active_community", text)) signals.push("active_community"); if (args.intent.needsPatientEducation && signalMatchesText("ed", text)) signals.push("ed"); @@ -320,6 +328,9 @@ function resultBoost(args: { intent: RetrievalIntent; candidate: RetrievalCandid if (args.intent.needsExactVisualTable && (signals.has("table") || signals.has("table_fact"))) boost += 0.08; if (args.intent.needsDoseRouteFrequency && signals.has("dose_amount")) boost += 0.08; if (args.intent.needsDoseRouteFrequency && signals.has("route")) boost += 0.08; + if (args.intent.requiredTermSignals.includes("clinical_subject")) { + boost += signals.has("clinical_subject") ? 0.24 : -0.24; + } if (args.intent.needsComparison && args.result.document_summary) boost += 0.03; if (signals.has("document_title")) boost += 0.05; if (signals.has("direct_relevance")) boost += 0.06; @@ -340,10 +351,12 @@ function resultBoost(args: { intent: RetrievalIntent; candidate: RetrievalCandid export function buildRetrievalIntent(query: string, queryClass: RagQueryClass): RetrievalIntent { const normalizedQuery = normalize(query); const asksDoseRoute = - /\b(?:dose|dosage|dosing|route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl|frequency|mg|mcg|prn)\b/.test( + /\b(?:dose|doses|dosage|dosages|dosing|route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl|frequency|mg|mcg|prn)\b/.test( normalizedQuery, ); - const asksDoseAmount = /\b(?:dose|dosage|dosing|mg|mcg|microgram|maximum|minimum)\b/.test(normalizedQuery); + const asksDoseAmount = /\b(?:dose|doses|dosage|dosages|dosing|mg|mcg|microgram|maximum|minimum)\b/.test( + normalizedQuery, + ); const asksTable = /\b(?:table|chart|matrix|threshold|cutoff|cut off|range|criteria|row)\b/.test(normalizedQuery); const asksSourceImage = /\b(?:source|show|open|view|display|see)\b.*\b(?:image|figure|visual|table|chart|matrix)\b/.test(normalizedQuery) || @@ -387,6 +400,9 @@ export function buildRetrievalIntent(query: string, queryClass: RagQueryClass): if (asksDoseAmount) requiredTermSignals.push("dose_amount"); if (/\b(?:route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl)\b/.test(normalizedQuery)) requiredTermSignals.push("route"); + if (queryClass === "medication_dose_risk" && medicationDoseQuerySubjectTokens(query).length > 0) { + requiredTermSignals.push("clinical_subject"); + } } if (asksFlowchart) { preferredDocumentSignals.push("flowchart", "pathway", "risk matrix"); @@ -452,7 +468,7 @@ export function buildRetrievalCandidates( matchedSignals: [], sourceHref: documentCitationHref(citationFromResult(result)), }; - const matchedSignals = matchedSignalsForResult({ intent, result, chunkType }); + const matchedSignals = matchedSignalsForResult({ query, intent, result, chunkType }); const lexicalScore = lexicalScoreForSignals(intent.requiredTermSignals, matchedSignals); const candidate = { ...initial, lexicalScore, matchedSignals }; // The relevance score stays CLAMPED: live hybrid scores routinely saturate at 1.0, and letting diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index c80bd2d39..3c93e2819 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -694,6 +694,69 @@ describe("retrieval query variants", () => { ).toMatchObject({ accepted: true, reason: "dose_route_amount_evidence_gate" }); }); + it("does not require route for a dose-only question when dose and subject are co-located", () => { + expect( + evaluateEvidenceCoverageGate( + "What medication doses are used for opioid withdrawal?", + [ + result({ + title: "Opioid use disorder", + content: "For opioid withdrawal, methadone 10 mg may be used initially.", + similarity: 0.8, + }), + ], + "medication_dose_risk", + ), + ).toMatchObject({ accepted: true, reason: "dose_route_amount_evidence_gate" }); + }); + + it("rejects entity-crossing dose evidence from an unrelated medication result", () => { + expect( + evaluateEvidenceCoverageGate( + "What medication doses are used for opioid withdrawal?", + [ + result({ content: "Opioid withdrawal management guidance.", similarity: 0.9 }), + result({ content: "For agitation, lorazepam 1 mg IM may be used.", similarity: 0.88 }), + ], + "medication_dose_risk", + ), + ).toMatchObject({ accepted: false, reason: "missing_dose_query_context" }); + }); + + it("ranks subject-matched dose evidence above stronger unrelated numeric dose results", () => { + const query = "What medication doses are used for opioid withdrawal?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "unrelated-dose", + title: "Nicotine Replacement Therapy", + content: "Nicotine patch 21 mg/24 hours and gum 4 mg are available.", + similarity: 0.9, + }), + result({ + id: "opioid-dose", + title: "Opioid use disorder", + content: "For opioid withdrawal, methadone 10 mg may be used initially.", + similarity: 0.55, + }), + ]); + + expect(ranked[0]?.id).toBe("opioid-dose"); + expect(ranked[0]?.score_explanation?.clinicalSignalBoost).toBeGreaterThan( + ranked[1]?.score_explanation?.clinicalSignalBoost ?? 0, + ); + expect(ranked[1]?.score_explanation?.rawPenalty).toBeLessThan(0); + + const selected = selectRetrievalEvidence({ + query, + queryClass: "medication_dose_risk", + results: ranked, + topK: 1, + maxResultsPerDocument: 2, + }); + expect(selected.intent.requiredTermSignals).toContain("clinical_subject"); + expect(selected.results[0]?.id).toBe("opioid-dose"); + }); + it("requires direct source image evidence for source image/table requests", () => { const query = "Show the source table image for the patient property restricted items table."; expect(