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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-17 | claude/s1b-rag-dosing-routing-6u1mik | 713df7a6128f0a8d9e63fd2c46e62c269aaaee9b | RAG answer routing: medication_dose_risk pre-deadline strong route (S1b/R1, #231) + extractive-first short-circuit signatures + golden allowedRoutes | PR #2035 open; behaviour change awaiting owner merge + post-merge canary pair | verify:pr-local heavy scope (lint, typecheck, full test, build, eval:rag:offline 586/586, medication checks) exit 0; focused vitest 103/103; rag-answer-fallback 90/90; check:rag:fixtures 36 golden cases; check:production-readiness offline-expected |
36 changes: 18 additions & 18 deletions docs/rag-improvement/HANDOVER.md

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions src/lib/rag/rag-eval-cases.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -762,7 +762,7 @@ export const ragEvalCases: RagEvalCase[] = [
expectedQueryClass: "medication_dose_risk",
supported: true,
expectedFiles: ["CG.MHSP.ClozapinePresAdminMonitor.pdf"],
allowedRoutes: ["extractive", "fast"],
allowedRoutes: ["extractive", "fast", "strong"],
minCitations: 1,
latencyTargetMs: 2000,
},
Expand DownExpand Up@@ -805,7 +805,7 @@ export const ragEvalCases: RagEvalCase[] = [
expectedQueryClass: "medication_dose_risk",
supported: true,
expectedFiles: ["MHSP.AgitationArousalPharmaMgt.pdf"],
allowedRoutes: ["extractive", "fast"],
allowedRoutes: ["extractive", "fast", "strong"],
minCitations: 2,
latencyTargetMs: 2000,
},
Expand DownExpand Up@@ -845,7 +845,7 @@ export const ragEvalCases: RagEvalCase[] = [
category: "routine",
supported: true,
expectedFiles: ["MHSP.LongActingInjectable.pdf"],
allowedRoutes: ["extractive", "fast"],
allowedRoutes: ["extractive", "fast", "strong"],
minCitations: 2,
latencyTargetMs: 2000,
},
Expand DownExpand Up@@ -1075,7 +1075,7 @@ export const ragEvalCases: RagEvalCase[] = [
expectedQueryClass: "medication_dose_risk",
supported: true,
expectedFiles: ["CG.MHSP.ClozapinePresAdminMonitor.pdf"],
allowedRoutes: ["extractive", "fast"],
allowedRoutes: ["extractive", "fast", "strong"],
minCitations: 1,
latencyTargetMs: 2000,
},
Expand All@@ -1098,7 +1098,7 @@ export const ragEvalCases: RagEvalCase[] = [
category: "routine",
supported: true,
expectedFiles: ["CG.MHSP.ClozapinePresAdminMonitor.pdf"],
allowedRoutes: ["extractive", "fast"],
allowedRoutes: ["extractive", "fast", "strong"],
minCitations: 1,
latencyTargetMs: 2000,
requireVisualEvidence: true,
Expand All@@ -1112,7 +1112,7 @@ export const ragEvalCases: RagEvalCase[] = [
expectedQueryClass: "medication_dose_risk",
supported: true,
expectedFiles: ["MHSP.AgitationArousalPharmaMgt.pdf"],
allowedRoutes: ["extractive", "fast"],
allowedRoutes: ["extractive", "fast", "strong"],
minCitations: 1,
latencyTargetMs: 2000,
},
Expand Down
9 changes: 5 additions & 4 deletions src/lib/rag/rag-extractive-first.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,6 +15,7 @@ import {
isSourceBoundCommunityHomeVisitRequirementsQuery,
retainCitedExtractiveFallbackEvidence,
} from "@/lib/rag/rag-extractive-answer";
import { MEDICATION_DOSE_RISK_STRONG_ROUTE_REASON } from "@/lib/rag/rag-routing";
import type { RagQueryClass, RetrievalConfidenceGateStatus, SearchResult } from "@/lib/types";

/**
Expand DownExpand Up@@ -300,8 +301,8 @@ export function hasValidatedGenericLaiManagementExtractiveAnswer(args: {
if (
!genericLaiManagementQuery ||
args.queryClass !== "medication_dose_risk" ||
args.route.mode !== "fast" ||
args.route.reason !== "clinical_fast_grounded_synthesis" ||
args.route.mode !== "strong" ||
args.route.reason !== MEDICATION_DOSE_RISK_STRONG_ROUTE_REASON ||
!args.sourceBacked
) {
return false;
Expand DownExpand Up@@ -337,8 +338,8 @@ export function hasValidatedAgitationArousalTypoDosingExtractiveAnswer(args: {
if (
normalizedQuery !== validatedAgitationArousalTypoDosingQuery ||
args.queryClass !== "medication_dose_risk" ||
args.route.mode !== "fast" ||
args.route.reason !== "clinical_fast_grounded_synthesis" ||
args.route.mode !== "strong" ||
args.route.reason !== MEDICATION_DOSE_RISK_STRONG_ROUTE_REASON ||
args.gateStatus !== "passed" ||
!args.sourceBacked
) {
Expand Down
17 changes: 17 additions & 0 deletions src/lib/rag/rag-routing.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,6 +23,13 @@ export type AnswerRoute = {

export const SOURCE_BACKED_REVIEW_FALLBACK_REASON = "source_backed_review_fallback";

// Dosing-class queries that reach the routed-generation fallthrough go to the strong
// route up front, so the 35s strong budget exists before the deadline is created.
// On the fast route their failed attempts retried strong inside the fast route's
// leftover budget (fast_unsupported_retry_strong) and died as provider_timeout —
// the dominant "Lithium dosing?" fallback mode after S1 (issue #231 residual R1).
export const MEDICATION_DOSE_RISK_STRONG_ROUTE_REASON = "medication_dose_risk_strong_route";

const unsupportedSimilarityThreshold = 0.32;
const strongRetrievalThreshold = 0.64;
const extractiveRetrievalThreshold = 0.76;
Expand DownExpand Up@@ -1164,6 +1171,16 @@ export function chooseAnswerRoute(args: {
};
}

if (queryClass === "medication_dose_risk") {
return {
mode: "strong",
model: args.strongModel,
reason: MEDICATION_DOSE_RISK_STRONG_ROUTE_REASON,
strongestScore,
documentCount: documents,
};
}

return {
mode: "fast",
model: args.fastModel,
Expand Down
65 changes: 39 additions & 26 deletions tests/rag-answer-fallback.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2027,15 +2027,15 @@ describe("RAG structured-output fallback", () => {
expect(answerCalls[0]?.[2].instructions).toContain("Within one named scale and source");
expect(answerCalls[0]?.[2].instructions).toContain("cite the smallest sufficient directly supporting chunk set");
expect(answerInput).toContain("answer_plan.intent: clinical_synthesis");
expect(answerInput).toContain("answer_plan.route_mode: fast");
expect(answerInput).toContain("answer_plan.model_strategy: fast_model_then_quality_gate");
expect(answerInput).toContain("answer_plan.route_mode: strong");
expect(answerInput).toContain("answer_plan.model_strategy: strong_model_then_quality_gate");
expect(answerInput).toContain("answer_plan.source_policy: required_citations");
expect(answer.routingMode).toBe("fast");
expect(answer.routingReason).toContain("clinical_fast_grounded_synthesis");
expect(answer.routingMode).toBe("strong");
expect(answer.routingReason).toContain("medication_dose_risk_strong_route");
expect(answer.smartApiPlan?.answerPlan).toMatchObject({
intent: "clinical_synthesis",
routeMode: "fast",
modelStrategy: "fast_model_then_quality_gate",
routeMode: "strong",
modelStrategy: "strong_model_then_quality_gate",
sourcePolicy: "required_citations",
});
expect(answer.answer.replace(/\*\*/g, "")).toMatch(/clozapine Monitoring Form/i);
Expand DownExpand Up@@ -2276,7 +2276,7 @@ describe("RAG structured-output fallback", () => {
expect(answer.answer).toMatch(/source support|indexed document|supports this query|ECT Procedure/i);
});

it("retries template-like fast answers with the strong model before returning", async () => {
it("retries template-like dosing-class strong answers with a quality retry before returning", async () => {
vi.stubEnv("OPENAI_API_KEY", "test-key");
vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0");
vi.stubEnv("RAG_ANSWER_CACHE_TTL_MS", "0");
Expand DownExpand Up@@ -2386,7 +2386,7 @@ describe("RAG structured-output fallback", () => {

expect(generateStructuredTextResult).toHaveBeenCalledTimes(2);
expect(answer.routingMode).toBe("strong");
expect(answer.routingReason).toContain("fast_template_retry_strong");
expect(answer.routingReason).toContain("strong_quality_retry");
expect(answer.openAIRequestIds ?? []).toEqual(["req_fast_template", "req_strong_natural"]);
expect(answer.grounded).toBe(true);
expect(answer.confidence).toBe("medium");
Expand DownExpand Up@@ -3561,7 +3561,7 @@ describe("RAG structured-output fallback", () => {
expect(new Set(answer.sources.map((result) => result.document_id))).toEqual(new Set(["fsh-lithium"]));
expect(answer.latencyTimings?.answer_retry_count).toBe(2);
expect(answer.latencyTimings?.answer_retry_reasons).toEqual([
"fast_max_output_tokens_retry_strong",
"strong_max_output_tokens_retry_strong",
"strong_max_output_tokens",
]);
expect(answer.openAIRequestIds).toEqual(["req_truncated_1", "req_truncated_2"]);
Expand DownExpand Up@@ -4481,7 +4481,7 @@ describe("RAG structured-output fallback", () => {

const plainAnswer = answer.answer.replace(/\*\*/g, "");
expect(generateStructuredTextResult).toHaveBeenCalledTimes(1);
expect(answer.routingMode).toBe("fast");
expect(answer.routingMode).toBe("strong");
expect(answer.grounded).toBe(true);
expect(plainAnswer).not.toMatch(/^Dosage\b/i);
expect(plainAnswer).not.toContain("alternative agent where possible");
Expand DownExpand Up@@ -4524,7 +4524,7 @@ describe("RAG structured-output fallback", () => {
);

const plainAnswer = answer.answer.replace(/\*\*/g, "");
expect(answer.routingMode).toBe("fast");
expect(answer.routingMode).toBe("strong");
expect(answer.grounded).toBe(true);
expect(plainAnswer).not.toMatch(/^Dosage\b/i);
expect(plainAnswer).not.toContain("chart reference only");
Expand DownExpand Up@@ -4992,14 +4992,17 @@ describe("budget-aware generation deadlines", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-14T00:00:00.000Z"));
vi.stubEnv("OPENAI_API_KEY", "test-key");
// Stubbed far above the 25_000ms fast-route budget so the granted timeout can only
// come from the deadline: budget - 2_000ms recovery reserve = 23_000ms. Reverting the
// call site to the reserve-free requestTimeoutMs would grant the full 25_000ms.
// Stubbed far above the granted window below so the value can only come from the
// deadline. The retrieval mock additionally burns 10_000ms of the 35_000ms strong
// budget, keeping the deadline term (35_000 - 10_000 - 2_000 = 23_000ms) below every
// plausible timeout cap — including the 30_000ms default that full-file runs pin when
// an earlier import froze env — so this assertion discriminates in both run modes.
// Reverting the call site to the reserve-free requestTimeoutMs would grant 25_000ms.
vi.stubEnv("OPENAI_ANSWER_TIMEOUT_MS", "60000");
vi.stubEnv("RAG_SEARCH_CACHE_TTL_MS", "0");
vi.stubEnv("RAG_ANSWER_CACHE_TTL_MS", "0");

// Same retrieval fixture as the model-synthesis test above, which routes fast.
// Same retrieval fixture as the model-synthesis test above, which routes strong.
const clozapineSource = source({
id: "clozapine-monitoring-1",
document_id: "clozapine-doc",
Expand All@@ -5013,8 +5016,17 @@ describe("budget-aware generation deadlines", () => {
hybrid_score: 0.94,
text_rank: 0,
});
let retrievalTimeBurned = false;
const rpc = vi.fn(async (name: string) => {
if (retrievalRpcBaseName(name) === "match_document_chunks_text") return { data: [clozapineSource], error: null };
if (retrievalRpcBaseName(name) === "match_document_chunks_text") {
// Burn retrieval wall-clock exactly once so the deadline term is the binding
// one at the generation call regardless of the effective timeout cap.
if (!retrievalTimeBurned) {
retrievalTimeBurned = true;
vi.setSystemTime(new Date(Date.now() + 10_000));
}
return { data: [clozapineSource], error: null };
}
if (retrievalRpcBaseName(name) === "get_related_document_metadata") return { data: [], error: null };
return { data: [], error: null };
});
Expand DownExpand Up@@ -5048,25 +5060,26 @@ describe("budget-aware generation deadlines", () => {
skipCache: true,
});

// Fake timers pin elapsed-at-call to exactly 0ms, so the received timeout must equal
// budget - reserve. This is the assertion that fails if generationRequestTimeoutMs is
// reverted to requestTimeoutMs at the generation call site.
// Fake timers pin elapsed-at-call to exactly the burned 10_000ms, so the received
// timeout must equal budget - burned - reserve. This is the assertion that fails if
// generationRequestTimeoutMs is reverted to requestTimeoutMs at the generation call
// site (that revert would grant 25_000ms).
expect(generateStructuredTextResult).toHaveBeenCalledTimes(1);
expect(grantedTimeoutsMs).toEqual([answerRouteBudgetMs.fast - generationRecoveryReserveMs]);
expect(answer.latencyTimings?.route_budget_ms).toBe(answerRouteBudgetMs.fast);
expect(grantedTimeoutsMs).toEqual([answerRouteBudgetMs.strong - 10_000 - generationRecoveryReserveMs]);
expect(answer.latencyTimings?.route_budget_ms).toBe(answerRouteBudgetMs.strong);
// The attempt used its whole window, yet the reserve kept the source-backed recovery
// inside the route budget.
expect(answer.latencyTimings?.route_deadline_exceeded).toBe(false);
expect(answer.latencyTimings?.total_latency_ms).toBeLessThan(answerRouteBudgetMs.fast);
expect(answer.latencyTimings?.total_latency_ms).toBeLessThan(answerRouteBudgetMs.strong);
expect(answer.routingReason).toContain("generation_fallback:provider_timeout");
expect(answer.sources.length).toBeGreaterThan(0);
});

it("skips the truncation self-heal when the budget reserve would be breached", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-14T00:00:00.000Z"));
// Burn 20_000ms of the 25_000ms fast budget inside the first attempt before it
// resolves truncated: the 5_000ms left is below generationRecoveryReserveMs +
// Burn 20_000ms of the 35_000ms strong budget inside the first attempt before it
// resolves truncated: the 15_000ms left is below generationRecoveryReserveMs +
// minimumGenerationRetryMs (22_000ms), so the strong self-heal must be skipped
// instead of spending the recovery reserve on a guaranteed-discard retry.
const { answer, generateStructuredTextResult } = await lithiumTruncatedGenerationAnswer(20_000);
Expand All@@ -5075,7 +5088,7 @@ describe("budget-aware generation deadlines", () => {
// The skip is recorded without counting as a retry; the terminal truncation throw
// then lands on the existing source-backed recovery.
expect(answer.latencyTimings?.answer_retry_reasons).toEqual([
"truncation_retry_skipped_budget_reserve:fast_max_output_tokens",
"truncation_retry_skipped_budget_reserve:strong_max_output_tokens",
"generation_max_output_tokens",
]);
expect(answer.latencyTimings?.answer_retry_count).toBe(1);
Expand All@@ -5097,7 +5110,7 @@ describe("budget-aware generation deadlines", () => {

expect(generateStructuredTextResult).toHaveBeenCalledTimes(2);
expect(answer.latencyTimings?.answer_retry_reasons).toEqual([
"fast_max_output_tokens_retry_strong",
"strong_max_output_tokens_retry_strong",
"strong_max_output_tokens",
]);
expect(answer.latencyTimings?.answer_retry_count).toBe(2);
Expand Down
18 changes: 16 additions & 2 deletions tests/rag-extractive-first.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -519,22 +519,30 @@ The details (i.e.: medication name, formulation, route, dose and directions for
query: "How are long acting injectables managed?",
queryClass: "medication_dose_risk",
results: laiResults,
route: { mode: "fast", reason: "clinical_fast_grounded_synthesis" },
route: { mode: "strong", reason: "medication_dose_risk_strong_route" },
});

expect(chooseValidatedExtractiveShortCircuit(args)).toEqual({
reasonMarker: "validated_generic_lai_management_extractive_answer",
});
// The LAI skip is only offered while the retrieval gate passed.
expect(chooseValidatedExtractiveShortCircuit({ ...args, gateStatus: "blocked" })).toBeNull();
// The dosing class no longer reaches the fast fallthrough; the retired fast
// signature must not qualify.
expect(
chooseValidatedExtractiveShortCircuit({
...args,
route: { mode: "fast", reason: "clinical_fast_grounded_synthesis" },
}),
).toBeNull();
});

it("short-circuits only the measured typo dosing query when its single-source candidate validates", () => {
const query = "What agitaton and arousl dosing guidance applies to psychiatric inpatients?";
const args = proceduralArgs({
query,
queryClass: "medication_dose_risk",
route: { mode: "fast", reason: "clinical_fast_grounded_synthesis" },
route: { mode: "strong", reason: "medication_dose_risk_strong_route" },
results: [
source({
id: "agitation-repeat-dose-guidance",
Expand All@@ -561,6 +569,12 @@ The details (i.e.: medication name, formulation, route, dose and directions for
).toBe(false);
expect(hasValidatedAgitationArousalTypoDosingExtractiveAnswer({ ...args, gateStatus: "blocked" })).toBe(false);
expect(hasValidatedAgitationArousalTypoDosingExtractiveAnswer({ ...args, sourceBacked: false })).toBe(false);
expect(
hasValidatedAgitationArousalTypoDosingExtractiveAnswer({
...args,
route: { mode: "fast", reason: "clinical_fast_grounded_synthesis" },
}),
).toBe(false);
});

it("returns the blocked-recovery marker for the score-blocked routine document lookup", () => {
Expand Down
36 changes: 33 additions & 3 deletions tests/rag-routing.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -68,9 +68,25 @@ describe("RAG answer routing", () => {
expect(selected.reason).toBe("strong_routine_retrieval");
});

it("uses fast model synthesis for routine medication questions with strong single-source support", () => {
it("routes routine dosing-class questions to the strong model before the deadline is created", () => {
const selected = route("What clozapine monitoring is required?", [source()]);

expect(selected.mode).toBe("strong");
expect(selected.model).toBe("strong-model");
expect(selected.reason).toBe("medication_dose_risk_strong_route");
});

it("routes bare dosing questions to the strong model instead of the fast fallthrough", () => {
const selected = route("Lithium dosing?", [source()]);

expect(selected.mode).toBe("strong");
expect(selected.model).toBe("strong-model");
expect(selected.reason).toBe("medication_dose_risk_strong_route");
});

it("keeps the table-threshold fallthrough on the fast synthesis route", () => {
const selected = route("What sedation score threshold applies?", [source({ text_rank: 0.05 })]);

expect(selected.mode).toBe("fast");
expect(selected.model).toBe("fast-model");
expect(selected.reason).toBe("clinical_fast_grounded_synthesis");
Expand DownExpand Up@@ -1145,8 +1161,9 @@ describe("RAG answer routing", () => {
});

it("retries a single-source clinical fast failure with the strong model when retrieval is strong", () => {
const selected = route("What clozapine monitoring is required?", [source()]);
const selected = route("What sedation score threshold applies?", [source({ text_rank: 0.05 })]);

expect(selected.reason).toBe("clinical_fast_grounded_synthesis");
expect(
shouldRetryWithStrongAfterFast({
route: selected,
Expand All@@ -1156,10 +1173,23 @@ describe("RAG answer routing", () => {
citations: [],
routingReason: "structured_parse_fallback",
},
results: [source()],
results: [source({ text_rank: 0.05 })],
}),
).toBe(true);
});

it("never enters the after-fast strong retry for the pre-deadline dosing strong route", () => {
const selected = route("Lithium dosing?", [source()]);

expect(selected.mode).toBe("strong");
expect(
shouldRetryWithStrongAfterFast({
route: selected,
answer: { grounded: false, confidence: "unsupported", citations: [] },
results: [source()],
}),
).toBe(false);
});
});

describe("adversarial-manipulation query guard", () => {
Expand Down
Loading