Skip to content
Closed
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
76 changes: 63 additions & 13 deletions src/lib/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3501,6 +3501,62 @@ function selectRankedRetrievalResults(args: {
return selection.results;
}

// Shared blood-monitoring term/action patterns for the clozapine "when do we withhold?" query
// shape. Declared once so the coverage gate (below) and the top-5 safety net stay in lockstep —
// they must agree on what counts as blood evidence and a withhold/cease action, or the gate could
// accept a top-5 the net believes is uncovered (or vice versa). None carry the /g flag, so `.test`
// is stateless and safe to reuse.
const clozapineBloodTermPattern = /\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include CBC-only blood-threshold phrasing

When the user asks this same clozapine withhold question using only CBC / complete blood count or white blood cell count rather than ANC/FBC/WBC/WCC, this predicate returns false because those blood terms are not in the shared pattern. The added CBC test is masked because it also contains neutrophil, so those common phrasings still skip the new promotion and the clozapine-specific coverage gate, leaving the answer vulnerable to the same rank-jitter failure.

Useful? React with 👍 / 👎.

const clozapineWithholdQueryPattern = /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped)\b/i;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route ceased queries through the coverage gate

Because this new query-shape pattern now includes ceased, a query like “when should clozapine be ceased based on WBC?” is supposed to receive the clozapine safety net, but decideTextFastPath still only blocks early table-threshold fast paths for cease/stop/withhold forms and not ceased. If the initial top results contain any strong structured-threshold-looking chunk, that phrasing can return before prepareCoverageGateResults runs, so the new promotion and stricter clozapine coverage gate are bypassed for one of the phrasings added here.

Useful? React with 👍 / 👎.

// The action set for EVIDENCE also accepts "red" — the source table encodes the withhold row as the
// Red zone — but the QUERY set does not, so an unrelated "red flag" question can't trip this shape.
const clozapineWithholdActionPattern = /\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped|red)\b/i;

// True only for the narrow, safety-critical question "what WBC/ANC/neutrophil threshold withholds
// clozapine?" (any phrasing/abbreviation). Shared by the coverage gate and the top-5 safety net.
export function isClozapineBloodActionThresholdQuery(query: string) {
return (
/\bclozapine\b/i.test(query) && clozapineBloodTermPattern.test(query) && clozapineWithholdQueryPattern.test(query)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the safety net to typo-corrected clozapine queries

This predicate checks only the raw query text for clozapine, but the search pipeline already typo-corrects clozapin to clozapine when building/ranking candidates, and the eval corpus includes the withhold-threshold phrasing “What ANC or FBC cut off means clozapin should be withheld?”. For that realistic typo, the same rank-jitter scenario still skips the new promotion entirely even though the candidate set is otherwise the clozapine monitoring set, leaving the safety net absent for an existing supported query form.

Useful? React with 👍 / 👎.

);
}

// A single chunk that carries BOTH the blood parameter and the withhold/cease/red action on a
// structured threshold (table facts / threshold unit / table crop). This is the one chunk that
// actually answers the question; a generic clozapine chunk that only names the drug does not count.
function resultHasBloodActionThresholdEvidence(result: SearchResult) {
if (!hasStructuredThresholdEvidence(result)) return false;
const text = evidenceTextForGate(result);
return clozapineBloodTermPattern.test(text) && clozapineWithholdActionPattern.test(text);
Comment on lines +3527 to +3529

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require real table evidence before promoting clozapine chunks

For clozapine withhold-threshold queries, this treats hasStructuredThresholdEvidence as if it only means table facts / threshold units / table crops, but that helper also returns true from a broad keyword fallback such as anc, withhold, or stop. When the top 5 lacks the answer table and a below-cutoff prose chunk says something like “monitor ANC” and “stop clozapine” without a numeric cutoff/table row, the new safety net can promote that non-answer chunk, after which the coverage gate accepts and skips vector retrieval even though the actual threshold is still absent.

Useful? React with 👍 / 👎.

}

// Safety net for clozapine blood-monitoring withhold-threshold questions (safety-critical output).
// The correct answer lives in ONE structured threshold table that pairs the blood parameter
// (WBC/ANC/neutrophil) with the withhold/cease/red action. The lexical query for these questions
// collapses to "clozapine monitoring" (buildClinicalTextSearchQuery), which pulls many sibling
// clozapine chunks; a small ranking jitter can float them above that single table and push it past
// rank `limit`, leaving a top-5 that names the drug but omits the actual threshold+action. When the
// query asks for exactly this AND the top-`limit` evidence does not already carry both the blood
// term and the action, promote the best already-retrieved threshold+action chunk from below the
// cutoff into the last top-`limit` slot. Pure reordering of the existing candidate set, scoped to
// this one query shape; a no-op whenever the evidence is already present, so it can never reorder a
// result set that already answers the question.
export function ensureClozapineBloodActionEvidenceRanked(
query: string,
results: SearchResult[],
limit = 5,
): SearchResult[] {
if (results.length <= limit || !isClozapineBloodActionThresholdQuery(query)) return results;
const topText = topEvidenceText(results, limit);
if (clozapineBloodTermPattern.test(topText) && clozapineWithholdActionPattern.test(topText)) return results;
Comment on lines +3549 to +3550

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require the ranked evidence in a single top-five result

This early no-op checks the concatenated top-five text, so unrelated top-five chunks can satisfy the blood term and action separately (for example one generic clozapine/ANC monitoring chunk plus another chunk mentioning a red/stop action) while the actual structured threshold+action table remains at rank 6. In that scenario this new safety net returns without promotion even though no top-five result carries the answer-bearing evidence, leaving the clozapine withhold threshold fragile in exactly the jitter case this change is meant to guard against; the check should use the same per-result structured threshold predicate used for promoted candidates.

Useful? React with 👍 / 👎.

const promoteIndex = results.findIndex(
(result, index) => index >= limit && resultHasBloodActionThresholdEvidence(result),
);
if (promoteIndex < 0) return results;
const promoted = results[promoteIndex];
const rest = results.filter((_, index) => index !== promoteIndex);
return [...rest.slice(0, limit - 1), promoted, ...rest.slice(limit - 1)];
}

export function evaluateEvidenceCoverageGate(
query: string,
results: SearchResult[],
Expand Down Expand Up @@ -3545,19 +3601,9 @@ export function evaluateEvidenceCoverageGate(
const hasDirectTitle = directTitleOrAliasSupport(query, top);

if (queryClass === "table_threshold") {
if (
/\bclozapine\b/i.test(query) &&
/\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i.test(query) &&
/\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped)\b/i.test(query)
) {
const hasBlood = hasAnyTerm(
evidenceText,
/\b(?:anc|fbc|wbc|wcc|neutrophil|neutrophils|full blood|white cell)\b/i,
);
const hasAction = hasAnyTerm(
evidenceText,
/\b(?:withhold|withheld|withholding|cease|ceased|stop|stopped|red)\b/i,
);
if (isClozapineBloodActionThresholdQuery(query)) {
const hasBlood = hasAnyTerm(evidenceText, clozapineBloodTermPattern);
const hasAction = hasAnyTerm(evidenceText, clozapineWithholdActionPattern);
return {
accepted: hasStructuredThreshold && hasBlood && hasAction,
reason:
Expand Down Expand Up @@ -3704,6 +3750,10 @@ async function prepareCoverageGateResults(args: {
telemetry: args.telemetry,
topK: args.topK,
});
// Final step, after all reranking: guarantee the clozapine withhold-threshold table is in the
// top-5 the coverage gate evaluates and the answer layer sees. Runs last so no later sort can
// undo it; a no-op for every other query and whenever the evidence is already ranked.
results = ensureClozapineBloodActionEvidenceRanked(args.query, results);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select enough candidates before applying the safety net

This call runs after selectRankedRetrievalResults has already truncated to the caller's topK, but the API accepts topK values down to 1 and a caller asking for 5 results is exactly asking for the top five. In that case a threshold table at rank 6 is discarded before the safety net runs, so results.length <= limit makes the helper no-op and the rank-jitter case this change is intended to fix still reaches the coverage gate without the answer-bearing table; fetch/select at least one candidate beyond the cutoff for this query shape before applying the promotion.

Useful? React with 👍 / 👎.

args.telemetry.rerank_latency_ms += Date.now() - startedAt;
return results;
}
Expand Down
86 changes: 86 additions & 0 deletions tests/retrieval-query-variants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { selectRetrievalEvidence } from "../src/lib/retrieval-selection";
import {
buildRetrievalQueryVariants,
decideTextFastPath,
ensureClozapineBloodActionEvidenceRanked,
evaluateEvidenceCoverageGate,
isClozapineBloodActionThresholdQuery,
retrievalPlanCacheQuery,
selectRagAliasExpansions,
shouldApplyUnsupportedSearchShortCircuit,
Expand Down Expand Up @@ -609,6 +611,90 @@ describe("retrieval query variants", () => {
).toMatchObject({ accepted: true, reason: "clozapine_blood_action_structured_threshold" });
});

it("classifies clozapine blood-action withhold-threshold questions across phrasings", () => {
for (const query of [
"What WCC (white cell count) or neutrophil threshold should withhold clozapine?",
"What CBC (complete blood count) or neutrophil threshold should withhold clozapine?",
"What ANC or FBC threshold should withhold clozapine?",
"When should clozapine be ceased based on the WBC result?",
]) {
expect(isClozapineBloodActionThresholdQuery(query)).toBe(true);
}
// Wrong drug, no withhold action, or no blood parameter must NOT trip the shape.
for (const query of [
"When should lithium be withheld based on levels?",
"What is the usual clozapine maintenance dose?",
"What is the red flag escalation pathway?",
]) {
expect(isClozapineBloodActionThresholdQuery(query)).toBe(false);
}
});

it("promotes the clozapine threshold+action table into the top 5 when jitter drops it below", () => {
const query = "What WCC (white cell count) or neutrophil threshold should withhold clozapine?";
// Five generic clozapine chunks that name the drug but carry no blood parameter or action,
// followed by the single structured table that actually answers the question at rank 6.
const generic = Array.from({ length: 5 }, (_, index) =>
result({
id: `generic-${index}`,
document_id: `doc-generic-${index}`,
title: "Clozapine General Information",
content: "Clozapine monitoring overview and administration notes.",
hybrid_score: 1 - index * 0.01,
}),
);
const thresholdTable = result({
id: "threshold-table",
document_id: "doc-cloz-table",
title: "Clozapine Prescribing Administration Monitoring",
content: "State WBC Neutrophil Outcome Red: withhold clozapine.",
hybrid_score: 0.9,
table_facts: [
tableFact({
table_title: "Clozapine blood monitoring",
clinical_parameter: "Neutrophil",
threshold_value: "< 1.5",
action: "Withhold clozapine (Red zone).",
}),
],
});

const ranked = ensureClozapineBloodActionEvidenceRanked(query, [...generic, thresholdTable]);

expect(ranked).toHaveLength(6);
// The table is pulled into the last top-5 slot; the weakest generic chunk drops to rank 6.
expect(ranked[4].id).toBe("threshold-table");
expect(ranked[5].id).toBe("generic-4");
expect(ranked.slice(0, 4).map((entry) => entry.id)).toEqual(["generic-0", "generic-1", "generic-2", "generic-3"]);
});

it("leaves ordering untouched when the threshold evidence is already in the top 5", () => {
const query = "What WCC (white cell count) or neutrophil threshold should withhold clozapine?";
const thresholdTable = result({
id: "threshold-table",
title: "Clozapine Prescribing Administration Monitoring",
content: "State WBC Neutrophil Outcome Red: withhold clozapine.",
table_facts: [tableFact({ clinical_parameter: "Neutrophil", threshold_value: "< 1.5", action: "Withhold." })],
});
const filler = Array.from({ length: 5 }, (_, index) =>
result({ id: `filler-${index}`, content: "Clozapine monitoring overview." }),
);
const input = [thresholdTable, ...filler];

const ranked = ensureClozapineBloodActionEvidenceRanked(query, input);

expect(ranked.map((entry) => entry.id)).toEqual(input.map((entry) => entry.id));
});

it("is a no-op for queries outside the clozapine blood-action shape", () => {
const query = "What is the safety plan checklist?";
const input = Array.from({ length: 6 }, (_, index) => result({ id: `chunk-${index}` }));

const ranked = ensureClozapineBloodActionEvidenceRanked(query, input);

expect(ranked).toBe(input);
});

it("requires both route and numeric dose evidence for dose-route fast gates", () => {
expect(
evaluateEvidenceCoverageGate(
Expand Down
Loading