diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index ca66ce943..ca3d3f091 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1131,6 +1131,10 @@ const genericMedicationDoseQueryTokens = new Set([ "usual", "im", "po", + "patient", + "recommend", + "recommendation", + "recommended", "sc", "sl", ]); @@ -1140,6 +1144,13 @@ export function medicationDoseQuerySubjectTokens(query: string) { return normalizedClinicalSearchTokens(query).filter((token) => !genericMedicationDoseQueryTokens.has(token)); } +/** Whether the query explicitly asks for dose, route, or frequency evidence. */ +export function isMedicationDoseEvidenceQuery(query: string) { + return /\b(?:dose|doses|dosage|dosages|dosing|route|oral|intramuscular|subcutaneous|subcut|sublingual|im|po|sc|sl|frequency|mg|mcg|microgram|maximum|minimum|prn)\b/i.test( + query, + ); +} + /** Require dose evidence to carry the medication question's clinical subject. */ export function medicationDoseQueryContext(query: string, result: SearchResult) { const subjectTokens = medicationDoseQuerySubjectTokens(query); diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 8daaf83fe..9c50cd178 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -110,6 +110,7 @@ import { expandClinicalQuery, hasDoseEvidenceSupport, hasStructuredThresholdEvidence, + isMedicationDoseEvidenceQuery, medicationDoseQueryContext, normalizedClinicalSearchTokens, rankClinicalResults, @@ -2608,6 +2609,10 @@ export function decideTextFastPath( if (queryClass === "medication_dose_risk" && !results.slice(0, 5).some((result) => hasDoseEvidenceSupport(result))) { return { returnFastPath: false, reason: "missing_dose_evidence" }; } + if (queryClass === "medication_dose_risk" && isMedicationDoseEvidenceQuery(query)) { + const doseCoverage = evaluateEvidenceCoverageGate(query, results, queryClass); + if (!doseCoverage.accepted) return { returnFastPath: false, reason: doseCoverage.reason }; + } if (queryClass === "table_threshold") { if (strongestScore >= 0.62 || topTextRank >= 0.045) { @@ -2921,10 +2926,14 @@ export function evaluateEvidenceCoverageGate( } if (queryClass === "medication_dose_risk") { + const asksDoseAmount = /\b(?:dose|doses|dosage|dosages|dosing|mg|mcg|microgram|maximum|minimum)\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 hasContextualDose = top.some( + const hasContextualDoseEvidence = top.some( + (result) => hasDoseEvidenceSupport(result) && medicationDoseQueryContext(query, result).matched, + ); + const hasContextualDoseAmount = top.some( (result) => hasDoseEvidenceSupport(result) && hasDoseAmountEvidenceForGate(result) && @@ -2933,18 +2942,21 @@ export function evaluateEvidenceCoverageGate( const hasContextualRoute = top.some( (result) => hasDoseEvidenceSupport(result) && - hasDoseAmountEvidenceForGate(result) && hasRouteEvidenceForGate(result) && medicationDoseQueryContext(query, result).matched, ); - const accepted = hasContextualDose && (!asksRoute || hasContextualRoute) && agitationOk; + const accepted = + hasContextualDoseEvidence && + (!asksDoseAmount || hasContextualDoseAmount) && + (!asksRoute || hasContextualRoute) && + agitationOk; return { accepted, reason: accepted ? "dose_route_amount_evidence_gate" : !hasDoseAmount ? "missing_dose_amount_evidence" - : !hasContextualDose + : !hasContextualDoseEvidence || (asksDoseAmount && !hasContextualDoseAmount) ? "missing_dose_query_context" : !hasContextualRoute && asksRoute ? "missing_route_evidence" diff --git a/tests/rag-answer-fallback.test.ts b/tests/rag-answer-fallback.test.ts index 460133bf6..80c395b2a 100644 --- a/tests/rag-answer-fallback.test.ts +++ b/tests/rag-answer-fallback.test.ts @@ -126,7 +126,7 @@ async function answerFromTextSources( }); vi.doMock("@/lib/openai", () => ({ - embedTextWithTelemetry: vi.fn(), + embedTextWithTelemetry: vi.fn(async () => ({ embedding: [0.1, 0.2, 0.3], cacheHit: false })), generateStructuredTextResult, })); diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index 3c93e2819..f8a20fc45 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -284,6 +284,30 @@ describe("retrieval query variants", () => { "medication_dose_risk", ), ).toEqual({ returnFastPath: true, reason: "dose_evidence_text_match" }); + + expect( + decideTextFastPath( + "How should agitation be managed when oral medication is refused?", + [ + result({ + content: "For agitation, use IM medication when oral medication is refused, with review and monitoring.", + similarity: 0.67, + }), + ], + "medication_dose_risk", + ), + ).toEqual({ returnFastPath: true, reason: "dose_evidence_text_match" }); + + expect( + decideTextFastPath( + "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", + ), + ).toEqual({ returnFastPath: false, reason: "missing_dose_query_context" }); }); it("keeps flowchart zone-action fast paths gated on action evidence", () => {