Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 number Diff line number Diff line change
@@ -0,0 +1 @@
| 2026-08-17 | claude/s1d-final-gate-gap-recovery-dxgrn2 | 42134f42b9fe8676af99cfb7dbe377ac1306c9e6 | S1d final-gate gap recovery: finalizeRagAnswerQualityCore extractive recovery for fast strong_routine_retrieval gap-like answers (rag-extractive-answer.ts + tests + behaviour-map) | PR #2054 open; behaviour change, post-merge canary pair owed (baseline 32039841070) | verify:pr-local heavy scope green (lint, typecheck, test, build, eval:rag:offline); focused vitest 227/227 + 91/91; check:rag:fixtures 36 golden; check:maintainability-budgets green; discriminating-fixture proof (2 fail without diff) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"version": 2,
"id": "776405e0-c2d9-4dec-b688-e26c22143f04",
"createdOn": "2026-08-17",
"action": "cancel",
"payload": {
"requestId": "e8a5480e-0b0e-4ab1-b7bd-6090699dd7c1",
"reason": "S1d implemented: finalizeRagAnswerQualityCore's gap conversion now attempts the same source-backed extractive recovery as the in-loop and outer-catch paths for fast strong_routine_retrieval answers over non-empty results (branch claude/s1d-final-gate-gap-recovery-dxgrn2, HANDOVER §2 S1d row updated in the same PR). No open work remains for this row. If this cancellation loses the race to an earlier reconcile that applied the add, close the materialized row with issues:done citing the same PR."
}
}
6 changes: 6 additions & 0 deletions docs/rag-behaviour/behaviour-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ free real estate for ordering keys (see §2's critical property).
the post-finalize source-safe gates in `rag.ts` (`claim_support_high_risk_gap`,
`material_source_governance_gap`, `numeric_band_coherence_gap`, `numeric_faithfulness_gap`)
and the `finalizeRagAnswerQualityCore` gates.
- Since 2026-08-17 (S1d) the finalizer's gap conversion first attempts the same
source-backed extractive recovery the loop and outer catch use
(`recoverFinalGateGapExtractively`): a fast + `strong_routine_retrieval` gap-like answer
over non-empty results rebuilds extractively (marker
`final_quality_gate_source_backed_recovery:<reason>`); empty-retrieval, strong-route, and
comparison/dose/threshold gaps stay terminal as `final_quality_gate:<reason>`.
- Since 2026-08-12 the quality-gate throw sites raise `GenerationQualityError`
(`src/lib/rag/rag-generation-quality-diagnostics.ts`) carrying `{stage, gateReason,
answerShape}` where `answerShape` is provider-safe counts/lengths only — never prose. The
Expand Down
38 changes: 19 additions & 19 deletions docs/rag-improvement/HANDOVER.md

Large diffs are not rendered by default.

79 changes: 78 additions & 1 deletion src/lib/rag/rag-extractive-answer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
} from "@/lib/rag/rag-answer-text";
import { cloneAnswer } from "@/lib/rag/rag-cache";
import { ragProviderMode } from "@/lib/rag/rag-provider";
import { buildSmartRagApiPlan } from "@/lib/smart-rag-api";
import {
isLowYieldClinicalText,
normalizeInlineBulletGlyphs,
Expand Down Expand Up @@ -3786,6 +3787,78 @@ function applyProviderLabels(answer: RagAnswer): RagAnswer {
};
}

/**
* Fast-route final-gate gap recovery: when a grounded, cited, low-confidence fast
* model answer over strong routine retrieval is only gap-like phrasing, rebuild a
* deterministic source-backed answer from the evidence it already cites instead of
* shipping a citation-free evidence gap. Mirrors the rag.ts generation-fallback
* recovery, which handles the thrown variants of the same failure — today, model
* phrasing alone decides which of the two branches a gap-shaped fast answer takes.
* Returns null to keep the gap terminal: genuinely empty retrieval, strong-route
* gaps, and comparison/dose/threshold classes (whose fallbacks need dedicated
* single-chunk handling) never recover here. The rebuilt candidate is routingMode
* "extractive", so the re-entrant validation finalize cannot re-trigger this
* fast-gated recovery.
*/
function recoverFinalGateGapExtractively(
answer: RagAnswer,
query: string,
queryClass: RagQueryClass,
gapReason: string,
): RagAnswer | null {
if (answer.routingMode !== "fast") return null;
if (!answer.routingReason?.includes("strong_routine_retrieval")) return null;
if ((answer.sources?.length ?? 0) === 0) return null;
if (queryClass === "comparison" || queryClass === "medication_dose_risk" || queryClass === "table_threshold") {
return null;
}
// Band-coherence source conflicts already have a throw-based recovery in rag.ts.
if (answer.routingReason.includes("numeric_band_coherence_gate_source_conflict")) return null;
const recoveryRouteReason = [
answer.routingReason,
`generation_fallback:${gapReason}`,
"source_backed_extractive_fallback",
`final_quality_gate_source_backed_recovery:${gapReason}`,
].join("; ");
const candidate = buildExtractiveAnswer({
query,
queryClass,
results: answer.sources,
quoteCards: answer.quoteCards ?? [],
documentBreakdown: answer.documentBreakdown ?? [],
evidenceSummary: answer.evidenceSummary,
sourceCoverage: answer.sourceCoverage,
conflictsOrGaps: answer.conflictsOrGaps ?? [],
visualEvidence: answer.visualEvidence ?? [],
bestSource: answer.bestSource ?? null,
smartPanel: answer.smartPanel,
relatedDocuments: answer.relatedDocuments ?? [],
routeReason: recoveryRouteReason,
timings: answer.latencyTimings,
});
if (!candidate.grounded || candidate.confidence === "unsupported" || candidate.citations.length === 0) return null;
if (isBareCrossReferenceAnswer(candidate.answer ?? "")) return null;
const smartApiPlan = buildSmartRagApiPlan({
query,
queryClass,
results: candidate.sources,
routeMode: "extractive",
routeReason: candidate.routingReason,
conflictsOrGaps: candidate.conflictsOrGaps ?? [],
});
const merged: RagAnswer = {
...answer,
...candidate,
modelUsed: null,
supportedClaims: undefined,
evidenceAssessments: undefined,
smartApiPlan,
responseMode: smartApiPlan.displayMode,
};
if (!isSafeExtractiveFallbackCandidate(merged, query, queryClass)) return null;
return merged;
}

// Public wrapper: runs quality finalization, then stamps provider/quality labels so the UI can
// disclose source-only (lower-quality) answers and verify-against-sources guidance.
/** Finalize rag answer quality. */
Expand Down Expand Up @@ -3826,8 +3899,12 @@ function finalizeRagAnswerQualityCore(answer: RagAnswer, query: string, queryCla
const existingGapAnswer =
gapLikeAnswer && (!answer.grounded || answer.routingMode === "strong" || answer.confidence === "low");
if (existingGapAnswer) {
const gapAnswer = finalQualityGapAnswer(query, queryClass);
const gapReason = answer.modelUsed ? "provider_source_gap" : "source_gap";
const recovered = recoverFinalGateGapExtractively(answer, query, queryClass, gapReason);
// Terminates: the recovered answer is routingMode "extractive", so the fast-gated
// recovery cannot re-fire on this re-run.
if (recovered) return finalizeRagAnswerQualityCore(recovered, query, queryClass);
const gapAnswer = finalQualityGapAnswer(query, queryClass);
return {
...answer,
answer: gapAnswer,
Expand Down
123 changes: 123 additions & 0 deletions tests/extractive-answer-formatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,129 @@ describe("provider source-gap lifecycle", () => {
}),
).toBe(false);
});

const dischargeChunks = [
figureChunk({
id: "discharge-planning-start",
document_id: "discharge-guidance",
title: "Admission to Discharge for Mental Health Inpatients (NMHS)",
file_name: "Admission to Discharge for Mental Health Inpatients (NMHS).pdf",
page_number: 4,
section_heading: "Discharge planning",
content:
"Clinicians will actively plan effective and timely discharge from the beginning of admission and review the plan throughout the inpatient stay.",
similarity: 0.97,
hybrid_score: 0.97,
}),
figureChunk({
id: "discharge-documentation",
document_id: "discharge-guidance",
title: "Admission to Discharge for Mental Health Inpatients (NMHS)",
file_name: "Admission to Discharge for Mental Health Inpatients (NMHS).pdf",
page_number: 5,
section_heading: "Discharge documentation",
content:
"The discharge plan must document ongoing care arrangements, communicate the plan with the consumer, and identify follow-up responsibilities.",
similarity: 0.97,
hybrid_score: 0.97,
}),
];

// The S1d defect shape: a hedged, cited, grounded, low-confidence fast answer whose
// lead misses providerSourceGapLeadPattern but whose body matches the finalizer's
// broad gap-like regex ("do not provide specific") and survives sanitizeAnswerText.
const hedgedGapLikeFastAnswer = (overrides: Partial<RagAnswer> = {}): RagAnswer =>
({
answer:
"Discharge planning begins at admission and the plan is reviewed during the inpatient stay. The discharge documents do not provide specific timing details.",
grounded: true,
confidence: "low",
citations: dischargeChunks.map((chunk) => citationFromResult(chunk)),
sources: dischargeChunks,
answerSections: [],
quoteCards: [],
bestSource: null,
routingReason: "strong_routine_retrieval",
routingMode: "fast",
modelUsed: "gpt-test",
queryClass: "broad_summary",
...overrides,
}) as RagAnswer;
const dischargeQuery = "Summarize the discharge guidance";

it("rebuilds a source-backed extractive answer when a cited low-confidence fast answer is only gap-like phrasing", () => {
const answer = finalizeRagAnswerQuality(hedgedGapLikeFastAnswer(), dischargeQuery, "broad_summary");

expect(answer.routingMode).toBe("extractive");
expect(answer.grounded).toBe(true);
expect(answer.confidence).not.toBe("unsupported");
expect(answer.citations.length).toBeGreaterThan(0);
expect(answer.responseMode).toBeDefined();
expect(answer.responseMode).not.toBe("evidence_gap");
expect(answer.answer).not.toMatch(/do not provide specific/i);
expect(answer.routingReason).toContain("generation_fallback:provider_source_gap");
expect(answer.routingReason).toContain("source_backed_extractive_fallback");
expect(answer.routingReason).toContain("final_quality_gate_source_backed_recovery:provider_source_gap");
expect(answer.routingReason).not.toMatch(/final_quality_gate:/);
expect(answer.modelUsed).toBeNull();
expect(answer.answerQualityTier).toBe("source_only");
});

it("keeps a zero-source fast gap-like answer terminal", () => {
const answer = finalizeRagAnswerQuality(
hedgedGapLikeFastAnswer({ sources: [], citations: [] }),
dischargeQuery,
"broad_summary",
);

expect(answer.grounded).toBe(false);
expect(answer.confidence).toBe("unsupported");
expect(answer.citations).toEqual([]);
expect(answer.responseMode).toBe("evidence_gap");
expect(answer.routingReason).toContain("final_quality_gate:provider_source_gap");
});

it("keeps a strong-route gap-like answer terminal", () => {
const answer = finalizeRagAnswerQuality(
hedgedGapLikeFastAnswer({ routingMode: "strong" }),
dischargeQuery,
"broad_summary",
);

expect(answer.grounded).toBe(false);
expect(answer.confidence).toBe("unsupported");
expect(answer.citations).toEqual([]);
expect(answer.responseMode).toBe("evidence_gap");
expect(answer.routingReason).toContain("final_quality_gate:provider_source_gap");
});

it("keeps a comparison-class gap-like answer terminal", () => {
const answer = finalizeRagAnswerQuality(
hedgedGapLikeFastAnswer({ queryClass: "comparison" }),
dischargeQuery,
"comparison",
);

expect(answer.grounded).toBe(false);
expect(answer.confidence).toBe("unsupported");
expect(answer.citations).toEqual([]);
expect(answer.responseMode).toBe("evidence_gap");
expect(answer.routingReason).toContain("final_quality_gate:provider_source_gap");
});

it("does not recover outside strong routine retrieval", () => {
const answer = finalizeRagAnswerQuality(
hedgedGapLikeFastAnswer({ routingReason: "retrieval_gap_or_conflict" }),
dischargeQuery,
"broad_summary",
);

expect(answer.grounded).toBe(false);
expect(answer.confidence).toBe("unsupported");
expect(answer.citations).toEqual([]);
expect(answer.responseMode).toBe("evidence_gap");
expect(answer.routingReason).toContain("final_quality_gate:provider_source_gap");
});
});

describe("extractive malformed-fragment signatures", () => {
Expand Down
50 changes: 50 additions & 0 deletions tests/rag-answer-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,56 @@ describe("RAG structured-output fallback", () => {
expect(answer.answer).not.toMatch(/^No current source/i);
});

it("recovers a grounded low-confidence cited source-gap phrasing at the final quality gate without a strong retry", async () => {
// The S1d shape: grounded, cited, confidence "low", substantive lead misses
// providerSourceGapLeadPattern, hedged body matches the finalizer's broad
// gap-like regex ("do not provide specific") and survives sanitizeAnswerText.
// It passes every in-loop fast-failure screen and used to collapse to a
// citation-free evidence_gap in finalizeRagAnswerQualityCore.
const dischargeSources = [
source({
id: "discharge-planning-start",
document_id: "discharge-guidance",
title: "Admission to Discharge for Mental Health Inpatients (NMHS)",
file_name: "Admission to Discharge for Mental Health Inpatients (NMHS).pdf",
section_heading: "Discharge planning",
content:
"Clinicians will actively plan effective and timely discharge from the beginning of admission and review the plan throughout the inpatient stay.",
}),
source({
id: "discharge-documentation",
document_id: "discharge-guidance",
title: "Admission to Discharge for Mental Health Inpatients (NMHS)",
file_name: "Admission to Discharge for Mental Health Inpatients (NMHS).pdf",
section_heading: "Discharge documentation",
content:
"The discharge plan must document ongoing care arrangements, communicate the plan with the consumer, and identify follow-up responsibilities.",
}),
];
const answer = await answerFromTextSources("Summarize the discharge guidance", dischargeSources, {
answer:
"Discharge planning begins at admission and the plan is reviewed during the inpatient stay. The discharge documents do not provide specific timing details.",
grounded: true,
confidence: "low",
answerSections: [],
citations: [{ chunk_id: "discharge-planning-start" }, { chunk_id: "discharge-documentation" }],
quoteCards: [],
conflictsOrGaps: [],
});

expect(answer.routingMode).toBe("extractive");
expect(answer.routingReason).toContain("generation_fallback:provider_source_gap");
expect(answer.routingReason).toContain("source_backed_extractive_fallback");
expect(answer.routingReason).toContain("final_quality_gate_source_backed_recovery:provider_source_gap");
expect(answer.routingReason).not.toMatch(/final_quality_gate:/);
expect(answer.grounded).toBe(true);
expect(answer.citations.length).toBeGreaterThan(0);
expect(answer.answer).not.toMatch(/do not provide specific/i);
expect(answer.latencyTimings?.answer_retry_reasons ?? []).not.toContain("fast_source_gap_retry_strong");
expect(answer.latencyTimings?.answer_retry_reasons ?? []).not.toContain("fast_unsupported_retry_strong");
expect(answer.latencyTimings?.answer_retry_reasons ?? []).not.toContain("fast_quality_retry_strong");
});

it("keeps provider-failed complex comparisons on the source-attributed comparison fallback", async () => {
const comparisonFact = (documentId: string, chunkId: string, value: string) => ({
id: `${documentId}-threshold`,
Expand Down
Loading