Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions src/lib/clinical-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 +
Expand Down
36 changes: 24 additions & 12 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ import {
expandClinicalQuery,
hasDoseEvidenceSupport,
hasStructuredThresholdEvidence,
medicationDoseQueryContext,
normalizedClinicalSearchTokens,
rankClinicalResults,
riskZoneActionPattern,
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Comment thread
BigSimmo marked this conversation as resolved.
);
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,
Expand Down
22 changes: 19 additions & 3 deletions src/lib/retrieval-selection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { citationFromResult, documentCitationHref } from "@/lib/citations";
import { medicationDoseQueryContext, medicationDoseQuerySubjectTokens } from "@/lib/clinical-search";
import type {
RagQueryClass,
RetrievalCandidate,
Expand Down Expand Up @@ -232,6 +233,7 @@ function signalMatchesText(signal: string, text: string) {
}

function matchedSignalsForResult(args: {
query: string;
intent: RetrievalIntent;
result: SearchResult;
chunkType: RetrievalChunkType;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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) ||
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions tests/retrieval-query-variants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down