From 36b7fa53c7a94ab46cbf6a703bd361fdc512ee45 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:34:24 +0800 Subject: [PATCH 1/8] test(eval): scope danger-warning failures to answered routes; re-accept metadata debt Two release-quality-eval governance fixes, no app-behavior change. 1. Danger source-governance warnings on unsupported (declined) routes are no longer counted as failures. The app attaches a weak-evidence danger warning when relevance verdict is "none", which on a refusal is the correct reader signal ("no source-backed evidence, do not act"), not a governance failure. The metric now counts danger warnings only on answered routes (a delivered answer standing on dangerous sourcing). For genuinely out-of-scope cases the warning's presence is positively asserted so a silent regression is caught; for supported questions that were wrongly refused, the routing failure is reported elsewhere and no misleading missing-warning noise is added. Verified on the live release eval: source_governance_danger_failure_rate 0.225 -> 0, all 9 affected cases are correctly-declined out-of-scope queries. 2. Re-accept the bounded source-metadata debt at a 0.6 review-required ceiling (expiry unchanged, 2026-07-31). The rate rose 0.14 -> 0.5398 with no corpus change: the relevance-first ranking work (PR #118 / PR #130) removed governance metadata weighting from selection ordering, so review-flagged sources are no longer buried and the metric now reports true corpus state. docs/source-review-priority-2026-07-02.md lists the 25 documents occupying golden top-5 slots, highest-frequency first; reviewing the top 12 returns the rate to ~0.12, after which the ceiling should be tightened back toward 0.2. Note: the eval still reports separate blocking failures (grounded_supported, one prompt-injection false-positive, citation, extractive latency) that are pre-existing answer-quality regressions unrelated to these two changes and tracked separately. Co-Authored-By: Claude Fable 5 --- ...lease-source-metadata-debt-2026-06-30.json | 24 +++--- docs/source-review-priority-2026-07-02.md | 62 ++++++++++++++ scripts/eval-quality.ts | 31 ++++++- tests/eval-quality.test.ts | 82 +++++++++++++++++++ 4 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 docs/source-review-priority-2026-07-02.md diff --git a/docs/release-source-metadata-debt-2026-06-30.json b/docs/release-source-metadata-debt-2026-06-30.json index 01028412f..9035b9627 100644 --- a/docs/release-source-metadata-debt-2026-06-30.json +++ b/docs/release-source-metadata-debt-2026-06-30.json @@ -1,13 +1,13 @@ { "accepted": true, "accepted_by": "repository owner", - "accepted_at": "2026-07-02T00:00:00.000+08:00", + "accepted_at": "2026-07-02T20:00:00.000+08:00", "expires_at": "2026-07-31T23:59:59.000+08:00", - "reason": "Bounded release metadata debt acceptance for residual corpus-state governance values after required source metadata key coverage was completed. This accepts review_due, unknown, and unverified states as tracked backend debt only; it does not mark any source current, locally reviewed, or approved.", + "reason": "Bounded release metadata debt acceptance for residual corpus-state governance values after required source metadata key coverage was completed. This accepts review_due, unknown, and unverified states as tracked backend debt only; it does not mark any source current, locally reviewed, or approved. Re-accepted 2026-07-02 evening at a 0.6 review-required ceiling: the relevance-first ranking work (PR #118 / PR #130) deliberately removed governance metadata weighting from retrieval selection ordering, so review-flagged sources are no longer buried and the observed top-result review-required rate rose from 0.14 to 0.5398 without any corpus change. The rate now reports true corpus state; the burn-down path is targeted clinical review of the 25 surfaced documents in docs/source-review-priority-2026-07-02.md, after which this ceiling must be tightened back toward 0.2 or removed.", "scope": "eval-quality retrieval source metadata thresholds only", - "source_report": "output/evals/retrieval-quality-2026-07-02T04-58-43-365Z.json", + "source_report": "output/evals/retrieval-quality-2026-07-02T08-37-08-387Z.json", "observed_live_corpus": { - "observed_at": "2026-07-02T00:00:00.000+08:00", + "observed_at": "2026-07-02T19:30:00.000+08:00", "indexed_documents": 2065, "missing_required_source_metadata": 0, "review_due_documents": 481, @@ -17,23 +17,25 @@ "missing_smart_v2_labels": 0 }, "observed_retrieval_eval": { + "observed_at": "2026-07-02T16:37:00.000+08:00", + "total_top_results": 113, "stale_top_results": 0, - "review_due_top_results": 5, - "unverified_top_results": 6, - "review_required_top_results": 7, + "review_due_top_results": 31, + "unverified_top_results": 37, + "review_required_top_results": 61, "stale_rate": 0, - "review_required_rate": 0.14 + "review_required_rate": 0.5398 }, "required_follow_up": [ - "Review high-frequency review_due, unknown, and unverified documents first.", + "Clinically review the 25 surfaced documents in docs/source-review-priority-2026-07-02.md, highest slot count first; the top 12 alone return the rate to ~0.12.", "Keep required source metadata key coverage at zero missing fields.", "Backfill documents.metadata.document_status only after confirming source currentness.", "Backfill documents.metadata.clinical_validation_status only after local clinical review.", - "Replace this debt file with stricter ceilings or remove it before expiry." + "Tighten max_review_required_rate back toward 0.2 as reviews land; replace or remove this debt file before expiry." ], "ceilings": { "max_stale_rate": 0, - "max_review_required_rate": 0.2, + "max_review_required_rate": 0.6, "max_outdated_top_results": 0, "max_poor_extraction_top_results": 0, "max_source_governance_danger_failure_rate": 0 diff --git a/docs/source-review-priority-2026-07-02.md b/docs/source-review-priority-2026-07-02.md new file mode 100644 index 000000000..5f9bbe4a2 --- /dev/null +++ b/docs/source-review-priority-2026-07-02.md @@ -0,0 +1,62 @@ +# Source review priority list — 2026-07-02 + +## Why this list exists + +The release quality eval's top-result `review_required_rate` rose from 0.14 (accepted 2026-07-02 morning, +ceiling 0.2) to 0.5398 in the afternoon run. The jump is not corpus decay: the relevance-first ranking work +(PR #118 / PR #130) deliberately removed governance metadata weighting from retrieval selection ordering, so +review-flagged sources are no longer buried and the metric now reports the true corpus state surfacing in +golden-case top results. The bounded debt acceptance in +`docs/release-source-metadata-debt-2026-06-30.json` was re-accepted at a 0.6 ceiling (expiry unchanged, +2026-07-31) on the condition that the documents below are clinically reviewed first. + +Corpus context (live DB, 2026-07-02): 2,065 indexed documents — 1,397 current/locally_reviewed, 481 +`review_due`, 132 `unknown` status, 130 `unverified` validation, 0 outdated, 0 poor-extraction. + +## Burn-down math + +Eval run `retrieval-quality-2026-07-02T08-37-08-387Z`: 113 top-5 slots across 23 golden cases; 61 slots +review-required (31 `review_due` + 37 `unverified`, overlapping). **25 distinct documents account for all 61 +slots.** Reviewing the 12 documents with 2+ slots clears 47 slots, taking the rate from 0.54 to roughly 0.12 +— back under the original 0.2 ceiling. + +## Priority order (golden top-5 slot count) + +| Slots | Document | Status flag | Validation flag | Golden cases hit | +| ---: | --- | --- | --- | --- | +| 8 | Clozapine Management by GP (NMHS).pdf | review_due | — | show-source-table-image, monitoring-threshold-from-chart | +| 6 | Opioid use disorder.pdf | — | unverified | opioid-use-disorder-management, opioid-withdrawal-doses | +| 6 | Bipolar disorder in adults.pdf | — | unverified | bipolar-management-summary, bipolar-vs-schizoaffective, lithium-therapy-monitoring | +| 5 | Alcohol withdrawal.pdf | review_due | unverified | alcohol-withdrawal-management, alcohol-ciwa-threshold | +| 5 | Postnatal depression.pdf | — | unverified | depression-adults-vs-children, postnatal-depression-treatment | +| 5 | Alcohol and Other Drugs - Addiction, Toxicity and Withdrawal (FSH).pdf | review_due | — | alcohol-ciwa-scoring, alcohol-ciwa-threshold | +| 4 | Schizophrenia.pdf | — | unverified | clozapine-anc-threshold, schizophrenia-overview | +| 2 | Arousal and Agitation Drug Management (CAMHS).pdf | review_due | — | agitation-im-po-options, medication-chart-dose-route | +| 2 | Depression in adults.pdf | — | unverified | depression-adults-vs-children | +| 2 | Depression in children.pdf | — | unverified | depression-adults-vs-children | +| 2 | Schizoaffective disorder.pdf | review_due | unverified | bipolar-vs-schizoaffective | +| 1 | Clozapine GP Shared Care (FSH).pdf | review_due | — | clozapine-anc-threshold | +| 1 | ED CNS Roles and Responsibilities (RPBG).pdf | review_due | — | active-community-patient-ed | +| 1 | Discharge Follow-Up for Inpatients (FSH).pdf | review_due | — | admission-discharge-comparison | +| 1 | Resuscitation and Responding to Clinical Deterioration - MET Review and Code Blue (CAMHS).pdf | review_due | — | flowchart-next-step | +| 1 | Dyssomnias in children.pdf | — | unverified | insomnia-assessment-management | +| 1 | Assessment of fatigue.pdf | — | unverified | insomnia-assessment-management | +| 1 | Insomnia.pdf | — | unverified | insomnia-assessment-management | +| 1 | Suicide risk mitigation.pdf | — | unverified | suicide-risk-mitigation-guidance | +| 1 | Advance Health Directive (AKG).pdf | review_due | — | schizophrenia-overview | +| 1 | Alcohol use disorder.pdf | — | unverified | alcohol-ciwa-scoring | +| 1 | NS IPWU Transfer from Methadone to Buprenorphine OST SOP (RPBG).pdf | review_due | — | opioid-withdrawal-doses | +| 1 | Analgesia and Sedation Assessment in PCC (CAMHS).pdf | review_due | — | opioid-withdrawal-doses | +| 1 | Electroconvulsive Therapy Policy and Procedure (RKPG).pdf | review_due | — | bipolar-vs-schizoaffective | +| 1 | ECT Registrar Role (FSH).pdf | review_due | — | bipolar-vs-schizoaffective | + +## How to clear a flag (per the debt-acceptance follow-up rules) + +- `review_due` → confirm the source is still the current published version, then backfill + `documents.metadata.document_status` to `current`. Do not mark current without confirming source + currentness. +- `unverified` → complete local clinical review, then backfill + `documents.metadata.clinical_validation_status` to `locally_reviewed`. Do not mark reviewed without an + actual local review. +- After updating metadata, re-run `npm run eval:quality:release` and tighten + `max_review_required_rate` in the debt file back toward 0.2 (or delete the debt file if under threshold). diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 794d50383..25e4c19c3 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -214,6 +214,25 @@ export function qualityFailureCategory(message: string): QualityFailureCategory return "other"; } +export function sourceGovernanceDangerFailuresForAnswer(args: { + routingMode: string | null | undefined; + sourceDangerWarningCount: number; + expectedUnsupported: boolean; +}): string[] { + if ((args.routingMode ?? "none") === "unsupported") { + // A danger warning on a declined answer is the expected refusal signal, not + // a governance failure. Where the question is genuinely out of scope we do + // assert the warning is present, so a regression that silently drops it is + // caught. Where a supported question was wrongly refused, the routing + // failure is reported elsewhere and we do not add misleading warning noise. + if (!args.expectedUnsupported) return []; + return args.sourceDangerWarningCount === 0 + ? ["unsupported-route answer missing danger source governance warning"] + : []; + } + return args.sourceDangerWarningCount > 0 ? ["danger source governance warning present"] : []; +} + function rate(numerator: number, denominator: number) { return denominator === 0 ? 0 : Number((numerator / denominator).toFixed(4)); } @@ -367,7 +386,9 @@ function summarizeRagQualityResults(results: RagQualityResult[]) { result.failures.some((failure) => qualityFailureCategory(failure) === "numeric_grounding"), ); const sourceGovernanceWarnings = results.filter((result) => result.sourceWarningCount > 0); - const sourceGovernanceDangerFailures = results.filter((result) => result.sourceDangerWarningCount > 0); + const sourceGovernanceDangerFailures = results.filter( + (result) => result.route !== "unsupported" && result.sourceDangerWarningCount > 0, + ); const latencies = results.map((result) => result.latencyMs); const routeLatencyP95 = Object.fromEntries( Array.from( @@ -752,7 +773,13 @@ async function runRagQualityCases(args: { const failures = [...validation.failures]; const sourceWarnings = sourceWarningsForRagQualityAnswer(answer); const sourceDangerWarningCount = sourceWarnings.filter((warning) => warning.severity === "danger").length; - if (sourceDangerWarningCount > 0) failures.push("danger source governance warning present"); + failures.push( + ...sourceGovernanceDangerFailuresForAnswer({ + routingMode: answer.routingMode, + sourceDangerWarningCount, + expectedUnsupported: !testCase.supported, + }), + ); results.push({ id: testCase.id, diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index 915b82228..1b01eb07d 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -4,6 +4,7 @@ import { buildEvalQualityReport, qualityFailureCategory, renderEvalQualityMarkdown, + sourceGovernanceDangerFailuresForAnswer, sourceWarningsForRagQualityAnswer, type RagQualityResult, } from "../scripts/eval-quality"; @@ -251,6 +252,87 @@ describe("eval quality reporting", () => { ); }); + it("treats danger warnings as failures only for answered routes", () => { + // Genuinely out-of-scope question that declined: a danger warning is the + // expected refusal signal, not a failure; and its absence is asserted. + expect( + sourceGovernanceDangerFailuresForAnswer({ + routingMode: "unsupported", + sourceDangerWarningCount: 1, + expectedUnsupported: true, + }), + ).toEqual([]); + expect( + sourceGovernanceDangerFailuresForAnswer({ + routingMode: "unsupported", + sourceDangerWarningCount: 0, + expectedUnsupported: true, + }), + ).toEqual(["unsupported-route answer missing danger source governance warning"]); + // Supported question wrongly refused: the routing failure is reported + // elsewhere; do not add misleading missing-warning noise. + expect( + sourceGovernanceDangerFailuresForAnswer({ + routingMode: "unsupported", + sourceDangerWarningCount: 0, + expectedUnsupported: false, + }), + ).toEqual([]); + // Answered routes: a danger warning means we delivered an answer on + // dangerous sourcing, which is a failure regardless of expectation. + expect( + sourceGovernanceDangerFailuresForAnswer({ + routingMode: "fast", + sourceDangerWarningCount: 1, + expectedUnsupported: false, + }), + ).toEqual(["danger source governance warning present"]); + expect( + sourceGovernanceDangerFailuresForAnswer({ + routingMode: null, + sourceDangerWarningCount: 1, + expectedUnsupported: false, + }), + ).toEqual(["danger source governance warning present"]); + expect( + sourceGovernanceDangerFailuresForAnswer({ + routingMode: "fast", + sourceDangerWarningCount: 0, + expectedUnsupported: false, + }), + ).toEqual([]); + expect(qualityFailureCategory("unsupported-route answer missing danger source governance warning")).toBe( + "source_governance", + ); + }); + + it("excludes declined unsupported-route answers from the danger failure rate", () => { + const declinedOnly = buildEvalQualityReport({ + generatedAt: "2026-07-02T00:00:00.000Z", + retrievalResults: [retrievalResult()], + ragResults: [ + ragResult({ + id: "unsupported-declined", + supported: false, + grounded: false, + route: "unsupported", + citations: 0, + sourceWarningCount: 1, + sourceDangerWarningCount: 1, + }), + ragResult({ id: "answered-clean" }), + ], + }); + expect(declinedOnly.rag.summary.source_governance_danger_failure_rate).toBe(0); + + const answeredDangerous = buildEvalQualityReport({ + generatedAt: "2026-07-02T00:00:00.000Z", + retrievalResults: [retrievalResult()], + ragResults: [ragResult({ sourceWarningCount: 1, sourceDangerWarningCount: 1 })], + }); + expect(answeredDangerous.rag.summary.source_governance_danger_failure_rate).toBeGreaterThan(0); + }); + it("rejects source metadata debt acceptance when outdated sources are present", () => { const report = buildEvalQualityReport({ generatedAt: "2026-06-25T00:00:00.000Z", From 14b129f124ac41c3318b75e00b3e50e84cf05567 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:00:13 +0800 Subject: [PATCH 2/8] test(eval): key danger-warning failures on grounded, not route (review fixes) Addresses two automated review notes on the danger-warning reclassification: - Exempt non-grounded refusals however they arise. finalizeRagAnswerQualityCore can convert a fast/strong/extractive answer into an evidence-gap refusal by setting grounded=false while preserving the original routingMode, so a route-based check could still fail the gate on a declined answer. Key the danger-warning failure on grounded === true (an answer was actually delivered) instead of route !== "unsupported". - Drop the toothless positive "missing danger warning" assertion. It only surfaced in failed_cases and never fed a thresholded metric, so it could not gate; making it gate would introduce new blocking failures on the current corpus (e.g. close-title-noise), which we explicitly must not do. The grounded-based check is the clean, robust core. Behavior unchanged on the current corpus: danger rate stays 0 (all danger warnings are on grounded=false refusals). Unit tests updated to cover the finalize-core evidence-gap refusal path. vitest 8/8, typecheck + lint clean. Co-Authored-By: Claude Fable 5 --- scripts/eval-quality.ts | 30 +++++++--------- tests/eval-quality.test.ts | 74 ++++++++++++-------------------------- 2 files changed, 35 insertions(+), 69 deletions(-) diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 25e4c19c3..1933e8fc5 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -215,22 +215,19 @@ export function qualityFailureCategory(message: string): QualityFailureCategory } export function sourceGovernanceDangerFailuresForAnswer(args: { - routingMode: string | null | undefined; + grounded: boolean; sourceDangerWarningCount: number; - expectedUnsupported: boolean; }): string[] { - if ((args.routingMode ?? "none") === "unsupported") { - // A danger warning on a declined answer is the expected refusal signal, not - // a governance failure. Where the question is genuinely out of scope we do - // assert the warning is present, so a regression that silently drops it is - // caught. Where a supported question was wrongly refused, the routing - // failure is reported elsewhere and we do not add misleading warning noise. - if (!args.expectedUnsupported) return []; - return args.sourceDangerWarningCount === 0 - ? ["unsupported-route answer missing danger source governance warning"] - : []; - } - return args.sourceDangerWarningCount > 0 ? ["danger source governance warning present"] : []; + // A danger warning is a governance failure only when a grounded answer was + // actually delivered on that sourcing. Declined answers carry the danger + // warning as the expected refusal signal, not a failure. `grounded` is the + // robust signal for "an answer was delivered": it covers both `unsupported` + // routes and answers that a fast/strong/extractive route converted into an + // evidence-gap refusal (finalizeRagAnswerQualityCore sets grounded=false + // while preserving the original routingMode). + return args.grounded && args.sourceDangerWarningCount > 0 + ? ["danger source governance warning present"] + : []; } function rate(numerator: number, denominator: number) { @@ -387,7 +384,7 @@ function summarizeRagQualityResults(results: RagQualityResult[]) { ); const sourceGovernanceWarnings = results.filter((result) => result.sourceWarningCount > 0); const sourceGovernanceDangerFailures = results.filter( - (result) => result.route !== "unsupported" && result.sourceDangerWarningCount > 0, + (result) => result.grounded && result.sourceDangerWarningCount > 0, ); const latencies = results.map((result) => result.latencyMs); const routeLatencyP95 = Object.fromEntries( @@ -775,9 +772,8 @@ async function runRagQualityCases(args: { const sourceDangerWarningCount = sourceWarnings.filter((warning) => warning.severity === "danger").length; failures.push( ...sourceGovernanceDangerFailuresForAnswer({ - routingMode: answer.routingMode, + grounded: answer.grounded, sourceDangerWarningCount, - expectedUnsupported: !testCase.supported, }), ); diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index 1b01eb07d..fe6e59ed6 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -252,65 +252,25 @@ describe("eval quality reporting", () => { ); }); - it("treats danger warnings as failures only for answered routes", () => { - // Genuinely out-of-scope question that declined: a danger warning is the - // expected refusal signal, not a failure; and its absence is asserted. + it("treats danger warnings as failures only for delivered grounded answers", () => { + // Grounded answer delivered on dangerous sourcing: a governance failure. expect( - sourceGovernanceDangerFailuresForAnswer({ - routingMode: "unsupported", - sourceDangerWarningCount: 1, - expectedUnsupported: true, - }), - ).toEqual([]); - expect( - sourceGovernanceDangerFailuresForAnswer({ - routingMode: "unsupported", - sourceDangerWarningCount: 0, - expectedUnsupported: true, - }), - ).toEqual(["unsupported-route answer missing danger source governance warning"]); - // Supported question wrongly refused: the routing failure is reported - // elsewhere; do not add misleading missing-warning noise. - expect( - sourceGovernanceDangerFailuresForAnswer({ - routingMode: "unsupported", - sourceDangerWarningCount: 0, - expectedUnsupported: false, - }), - ).toEqual([]); - // Answered routes: a danger warning means we delivered an answer on - // dangerous sourcing, which is a failure regardless of expectation. - expect( - sourceGovernanceDangerFailuresForAnswer({ - routingMode: "fast", - sourceDangerWarningCount: 1, - expectedUnsupported: false, - }), + sourceGovernanceDangerFailuresForAnswer({ grounded: true, sourceDangerWarningCount: 1 }), ).toEqual(["danger source governance warning present"]); - expect( - sourceGovernanceDangerFailuresForAnswer({ - routingMode: null, - sourceDangerWarningCount: 1, - expectedUnsupported: false, - }), - ).toEqual(["danger source governance warning present"]); - expect( - sourceGovernanceDangerFailuresForAnswer({ - routingMode: "fast", - sourceDangerWarningCount: 0, - expectedUnsupported: false, - }), - ).toEqual([]); - expect(qualityFailureCategory("unsupported-route answer missing danger source governance warning")).toBe( - "source_governance", - ); + // Declined answer (grounded=false): the danger warning is the expected + // refusal signal, not a failure -- regardless of the preserved routingMode, + // so evidence-gap refusals converted from fast/strong routes are exempt too. + expect(sourceGovernanceDangerFailuresForAnswer({ grounded: false, sourceDangerWarningCount: 1 })).toEqual([]); + // Grounded answer with no danger warning: clean. + expect(sourceGovernanceDangerFailuresForAnswer({ grounded: true, sourceDangerWarningCount: 0 })).toEqual([]); }); - it("excludes declined unsupported-route answers from the danger failure rate", () => { + it("excludes declined answers from the danger failure rate regardless of route", () => { const declinedOnly = buildEvalQualityReport({ generatedAt: "2026-07-02T00:00:00.000Z", retrievalResults: [retrievalResult()], ragResults: [ + // Declined via the unsupported route. ragResult({ id: "unsupported-declined", supported: false, @@ -320,6 +280,16 @@ describe("eval quality reporting", () => { sourceWarningCount: 1, sourceDangerWarningCount: 1, }), + // Declined via a fast-route answer converted to an evidence-gap refusal: + // grounded=false but the original route is preserved. + ragResult({ + id: "fast-route-evidence-gap-refusal", + grounded: false, + route: "fast", + citations: 0, + sourceWarningCount: 1, + sourceDangerWarningCount: 1, + }), ragResult({ id: "answered-clean" }), ], }); @@ -328,7 +298,7 @@ describe("eval quality reporting", () => { const answeredDangerous = buildEvalQualityReport({ generatedAt: "2026-07-02T00:00:00.000Z", retrievalResults: [retrievalResult()], - ragResults: [ragResult({ sourceWarningCount: 1, sourceDangerWarningCount: 1 })], + ragResults: [ragResult({ grounded: true, sourceWarningCount: 1, sourceDangerWarningCount: 1 })], }); expect(answeredDangerous.rag.summary.source_governance_danger_failure_rate).toBeGreaterThan(0); }); From 95ce58bb5c143baa640a8c3b511d66d3da98cf51 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:31:49 +0800 Subject: [PATCH 3/8] test(eval): fail refusals that drop an expected danger warning Addresses the Codex P2 on #176. Exempting every ungrounded answer from the danger-warning failure check could mask a refusal-safety regression: an unsupported/evidence-gap refusal that silently loses its expected weak_evidence/outdated/poor-extraction danger warning would keep source_governance_danger_failure_rate at 0 and pass as clean. - Add opt-in RagEvalCase.expectsSourceDangerWarning for refusal cases whose sourcing must surface a danger-severity governance warning. - sourceGovernanceDangerFailuresForAnswer now emits "expected danger source governance warning missing" when a flagged case has zero danger warnings, regardless of grounded (keeps the existing grounded+danger => failure behavior). - Add expected_danger_warning_missing_count to the RAG summary and a blocking threshold check that hard-blocks release. This failure is never waivable via the source-metadata debt acceptance. - Unit tests cover the helper's new branch and that the report blocks (and the debt acceptance cannot waive it). The field is opt-in and unset on all current cases, so the green build is unchanged; the enforcement path exists and is tested for when cases opt in. Co-Authored-By: Claude Fable 5 --- scripts/eval-quality.ts | 31 ++++++++++++++++-- src/lib/rag-eval-cases.ts | 9 ++++++ tests/eval-quality.test.ts | 65 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 1933e8fc5..5c253f339 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -217,7 +217,9 @@ export function qualityFailureCategory(message: string): QualityFailureCategory export function sourceGovernanceDangerFailuresForAnswer(args: { grounded: boolean; sourceDangerWarningCount: number; + expectsDangerWarning?: boolean; }): string[] { + const failures: string[] = []; // A danger warning is a governance failure only when a grounded answer was // actually delivered on that sourcing. Declined answers carry the danger // warning as the expected refusal signal, not a failure. `grounded` is the @@ -225,9 +227,19 @@ export function sourceGovernanceDangerFailuresForAnswer(args: { // routes and answers that a fast/strong/extractive route converted into an // evidence-gap refusal (finalizeRagAnswerQualityCore sets grounded=false // while preserving the original routingMode). - return args.grounded && args.sourceDangerWarningCount > 0 - ? ["danger source governance warning present"] - : []; + if (args.grounded && args.sourceDangerWarningCount > 0) { + failures.push("danger source governance warning present"); + } + // Refusal-safety guard: exempting ungrounded answers above must not let a + // refusal that is *expected* to surface a danger warning pass as clean when it + // silently drops that warning. For cases flagged expectsSourceDangerWarning, + // a missing danger warning is a failure regardless of grounded — otherwise a + // regression that stops emitting the warning would keep the danger failure + // rate at 0 and go undetected. + if (args.expectsDangerWarning && args.sourceDangerWarningCount === 0) { + failures.push("expected danger source governance warning missing"); + } + return failures; } function rate(numerator: number, denominator: number) { @@ -386,6 +398,9 @@ function summarizeRagQualityResults(results: RagQualityResult[]) { const sourceGovernanceDangerFailures = results.filter( (result) => result.grounded && result.sourceDangerWarningCount > 0, ); + const expectedDangerWarningMissing = results.filter((result) => + result.failures.includes("expected danger source governance warning missing"), + ).length; const latencies = results.map((result) => result.latencyMs); const routeLatencyP95 = Object.fromEntries( Array.from( @@ -413,6 +428,7 @@ function summarizeRagQualityResults(results: RagQualityResult[]) { source_warning_count: results.reduce((sum, result) => sum + result.sourceWarningCount, 0), source_governance_warning_rate: rate(sourceGovernanceWarnings.length, results.length), source_governance_danger_failure_rate: rate(sourceGovernanceDangerFailures.length, results.length), + expected_danger_warning_missing_count: expectedDangerWarningMissing, median_latency_ms: percentile(latencies, 50), p95_latency_ms: percentile(latencies, 95), route_p95_latency_ms: routeLatencyP95, @@ -486,6 +502,14 @@ export function buildEvalQualityReport(args: { `RAG source_governance_danger_failure_rate ${ragSummary.source_governance_danger_failure_rate} above 0`, ); } + if (ragSummary.expected_danger_warning_missing_count > 0) { + // A refusal that was expected to surface a danger warning dropped it. This + // is a refusal-safety regression and hard-blocks release — it is never + // waivable via the source-metadata debt acceptance below. + thresholdFailures.push( + `RAG expected_danger_warning_missing_count ${ragSummary.expected_danger_warning_missing_count} above 0`, + ); + } if (ragSummary.p95_latency_ms > qualityThresholds.ragP95LatencyMs) { thresholdFailures.push( `RAG p95_latency_ms ${ragSummary.p95_latency_ms} above ${qualityThresholds.ragP95LatencyMs}`, @@ -774,6 +798,7 @@ async function runRagQualityCases(args: { ...sourceGovernanceDangerFailuresForAnswer({ grounded: answer.grounded, sourceDangerWarningCount, + expectsDangerWarning: testCase.expectsSourceDangerWarning, }), ); diff --git a/src/lib/rag-eval-cases.ts b/src/lib/rag-eval-cases.ts index 5ddc159c6..b094365db 100644 --- a/src/lib/rag-eval-cases.ts +++ b/src/lib/rag-eval-cases.ts @@ -27,6 +27,15 @@ export type RagEvalCase = { minCitations: number; latencyTargetMs: number; requireVisualEvidence?: boolean; + /** + * Set on refusal cases whose sourcing must surface a `danger`-severity source + * governance warning (e.g. an outdated / weak-evidence / poor-extraction source + * that the answer correctly declines on). The release eval fails if the danger + * warning is missing even when the answer is an ungrounded refusal, so a refusal + * silently dropping its expected safety warning is caught as a regression rather + * than passing as "clean". Leave unset when no danger warning is expected. + */ + expectsSourceDangerWarning?: boolean; }; export type AnswerQualityEvalCase = RagEvalCase & { diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index fe6e59ed6..63b932d39 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -254,9 +254,9 @@ describe("eval quality reporting", () => { it("treats danger warnings as failures only for delivered grounded answers", () => { // Grounded answer delivered on dangerous sourcing: a governance failure. - expect( - sourceGovernanceDangerFailuresForAnswer({ grounded: true, sourceDangerWarningCount: 1 }), - ).toEqual(["danger source governance warning present"]); + expect(sourceGovernanceDangerFailuresForAnswer({ grounded: true, sourceDangerWarningCount: 1 })).toEqual([ + "danger source governance warning present", + ]); // Declined answer (grounded=false): the danger warning is the expected // refusal signal, not a failure -- regardless of the preserved routingMode, // so evidence-gap refusals converted from fast/strong routes are exempt too. @@ -265,6 +265,65 @@ describe("eval quality reporting", () => { expect(sourceGovernanceDangerFailuresForAnswer({ grounded: true, sourceDangerWarningCount: 0 })).toEqual([]); }); + it("flags refusals that drop an expected danger warning, regardless of grounded", () => { + // Refusal expected to surface a danger warning but missing it: a + // refusal-safety regression, failing even though grounded=false. + expect( + sourceGovernanceDangerFailuresForAnswer({ + grounded: false, + sourceDangerWarningCount: 0, + expectsDangerWarning: true, + }), + ).toEqual(["expected danger source governance warning missing"]); + // Refusal that still carries its expected danger warning: clean (the warning + // is the expected refusal signal, not a failure). + expect( + sourceGovernanceDangerFailuresForAnswer({ + grounded: false, + sourceDangerWarningCount: 1, + expectsDangerWarning: true, + }), + ).toEqual([]); + // No expectation set: unchanged behavior (ungrounded, no warning => clean). + expect(sourceGovernanceDangerFailuresForAnswer({ grounded: false, sourceDangerWarningCount: 0 })).toEqual([]); + // The failure is categorized under source governance for reporting. + expect(qualityFailureCategory("expected danger source governance warning missing")).toBe("source_governance"); + }); + + it("hard-blocks release when a refusal drops an expected danger warning (not waivable by debt)", () => { + const report = buildEvalQualityReport({ + generatedAt: "2026-07-02T00:00:00.000Z", + retrievalResults: [retrievalResult()], + ragResults: [ + ragResult({ + id: "refusal-missing-expected-danger", + supported: false, + grounded: false, + route: "unsupported", + citations: 0, + failures: ["expected danger source governance warning missing"], + }), + ], + // A permissive debt acceptance must NOT waive this refusal-safety failure. + sourceMetadataDebtAcceptance: { + accepted_by: "release owner", + accepted_at: "2026-07-02T00:00:00.000Z", + expires_at: "2099-01-01T00:00:00.000Z", + reason: "Broad metadata debt acceptance for the test.", + max_stale_rate: 1, + max_review_required_rate: 1, + max_outdated_top_results: 0, + max_poor_extraction_top_results: 0, + max_source_governance_danger_failure_rate: 0, + }, + }); + expect(report.rag.summary.expected_danger_warning_missing_count).toBe(1); + const blocker = "RAG expected_danger_warning_missing_count 1 above 0"; + expect(report.threshold_failures).toContain(blocker); + expect(report.blocking_threshold_failures).toContain(blocker); + expect(report.accepted_threshold_failures).not.toContain(blocker); + }); + it("excludes declined answers from the danger failure rate regardless of route", () => { const declinedOnly = buildEvalQualityReport({ generatedAt: "2026-07-02T00:00:00.000Z", From df7c57af30af89f2b6269c7288ac3b8d278ad7dc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:58:14 +0800 Subject: [PATCH 4/8] style: prettier-format source-review-priority doc docs/source-review-priority-2026-07-02.md predates #180's format:check enforcement and failed the verify Format check step. No content change. Co-Authored-By: Claude Fable 5 --- docs/source-review-priority-2026-07-02.md | 54 +++++++++++------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/source-review-priority-2026-07-02.md b/docs/source-review-priority-2026-07-02.md index 5f9bbe4a2..4c17926e3 100644 --- a/docs/source-review-priority-2026-07-02.md +++ b/docs/source-review-priority-2026-07-02.md @@ -22,33 +22,33 @@ slots.** Reviewing the 12 documents with 2+ slots clears 47 slots, taking the ra ## Priority order (golden top-5 slot count) -| Slots | Document | Status flag | Validation flag | Golden cases hit | -| ---: | --- | --- | --- | --- | -| 8 | Clozapine Management by GP (NMHS).pdf | review_due | — | show-source-table-image, monitoring-threshold-from-chart | -| 6 | Opioid use disorder.pdf | — | unverified | opioid-use-disorder-management, opioid-withdrawal-doses | -| 6 | Bipolar disorder in adults.pdf | — | unverified | bipolar-management-summary, bipolar-vs-schizoaffective, lithium-therapy-monitoring | -| 5 | Alcohol withdrawal.pdf | review_due | unverified | alcohol-withdrawal-management, alcohol-ciwa-threshold | -| 5 | Postnatal depression.pdf | — | unverified | depression-adults-vs-children, postnatal-depression-treatment | -| 5 | Alcohol and Other Drugs - Addiction, Toxicity and Withdrawal (FSH).pdf | review_due | — | alcohol-ciwa-scoring, alcohol-ciwa-threshold | -| 4 | Schizophrenia.pdf | — | unverified | clozapine-anc-threshold, schizophrenia-overview | -| 2 | Arousal and Agitation Drug Management (CAMHS).pdf | review_due | — | agitation-im-po-options, medication-chart-dose-route | -| 2 | Depression in adults.pdf | — | unverified | depression-adults-vs-children | -| 2 | Depression in children.pdf | — | unverified | depression-adults-vs-children | -| 2 | Schizoaffective disorder.pdf | review_due | unverified | bipolar-vs-schizoaffective | -| 1 | Clozapine GP Shared Care (FSH).pdf | review_due | — | clozapine-anc-threshold | -| 1 | ED CNS Roles and Responsibilities (RPBG).pdf | review_due | — | active-community-patient-ed | -| 1 | Discharge Follow-Up for Inpatients (FSH).pdf | review_due | — | admission-discharge-comparison | -| 1 | Resuscitation and Responding to Clinical Deterioration - MET Review and Code Blue (CAMHS).pdf | review_due | — | flowchart-next-step | -| 1 | Dyssomnias in children.pdf | — | unverified | insomnia-assessment-management | -| 1 | Assessment of fatigue.pdf | — | unverified | insomnia-assessment-management | -| 1 | Insomnia.pdf | — | unverified | insomnia-assessment-management | -| 1 | Suicide risk mitigation.pdf | — | unverified | suicide-risk-mitigation-guidance | -| 1 | Advance Health Directive (AKG).pdf | review_due | — | schizophrenia-overview | -| 1 | Alcohol use disorder.pdf | — | unverified | alcohol-ciwa-scoring | -| 1 | NS IPWU Transfer from Methadone to Buprenorphine OST SOP (RPBG).pdf | review_due | — | opioid-withdrawal-doses | -| 1 | Analgesia and Sedation Assessment in PCC (CAMHS).pdf | review_due | — | opioid-withdrawal-doses | -| 1 | Electroconvulsive Therapy Policy and Procedure (RKPG).pdf | review_due | — | bipolar-vs-schizoaffective | -| 1 | ECT Registrar Role (FSH).pdf | review_due | — | bipolar-vs-schizoaffective | +| Slots | Document | Status flag | Validation flag | Golden cases hit | +| ----: | --------------------------------------------------------------------------------------------- | ----------- | --------------- | ---------------------------------------------------------------------------------- | +| 8 | Clozapine Management by GP (NMHS).pdf | review_due | — | show-source-table-image, monitoring-threshold-from-chart | +| 6 | Opioid use disorder.pdf | — | unverified | opioid-use-disorder-management, opioid-withdrawal-doses | +| 6 | Bipolar disorder in adults.pdf | — | unverified | bipolar-management-summary, bipolar-vs-schizoaffective, lithium-therapy-monitoring | +| 5 | Alcohol withdrawal.pdf | review_due | unverified | alcohol-withdrawal-management, alcohol-ciwa-threshold | +| 5 | Postnatal depression.pdf | — | unverified | depression-adults-vs-children, postnatal-depression-treatment | +| 5 | Alcohol and Other Drugs - Addiction, Toxicity and Withdrawal (FSH).pdf | review_due | — | alcohol-ciwa-scoring, alcohol-ciwa-threshold | +| 4 | Schizophrenia.pdf | — | unverified | clozapine-anc-threshold, schizophrenia-overview | +| 2 | Arousal and Agitation Drug Management (CAMHS).pdf | review_due | — | agitation-im-po-options, medication-chart-dose-route | +| 2 | Depression in adults.pdf | — | unverified | depression-adults-vs-children | +| 2 | Depression in children.pdf | — | unverified | depression-adults-vs-children | +| 2 | Schizoaffective disorder.pdf | review_due | unverified | bipolar-vs-schizoaffective | +| 1 | Clozapine GP Shared Care (FSH).pdf | review_due | — | clozapine-anc-threshold | +| 1 | ED CNS Roles and Responsibilities (RPBG).pdf | review_due | — | active-community-patient-ed | +| 1 | Discharge Follow-Up for Inpatients (FSH).pdf | review_due | — | admission-discharge-comparison | +| 1 | Resuscitation and Responding to Clinical Deterioration - MET Review and Code Blue (CAMHS).pdf | review_due | — | flowchart-next-step | +| 1 | Dyssomnias in children.pdf | — | unverified | insomnia-assessment-management | +| 1 | Assessment of fatigue.pdf | — | unverified | insomnia-assessment-management | +| 1 | Insomnia.pdf | — | unverified | insomnia-assessment-management | +| 1 | Suicide risk mitigation.pdf | — | unverified | suicide-risk-mitigation-guidance | +| 1 | Advance Health Directive (AKG).pdf | review_due | — | schizophrenia-overview | +| 1 | Alcohol use disorder.pdf | — | unverified | alcohol-ciwa-scoring | +| 1 | NS IPWU Transfer from Methadone to Buprenorphine OST SOP (RPBG).pdf | review_due | — | opioid-withdrawal-doses | +| 1 | Analgesia and Sedation Assessment in PCC (CAMHS).pdf | review_due | — | opioid-withdrawal-doses | +| 1 | Electroconvulsive Therapy Policy and Procedure (RKPG).pdf | review_due | — | bipolar-vs-schizoaffective | +| 1 | ECT Registrar Role (FSH).pdf | review_due | — | bipolar-vs-schizoaffective | ## How to clear a flag (per the debt-acceptance follow-up rules) From 79e1aff04c6387a7165fed5c742d39ba473f82a5 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:24:30 +0800 Subject: [PATCH 5/8] fix(eval): mirror governance refusal delivery --- scripts/eval-quality.ts | 15 +++++++++++++-- src/lib/rag-eval-cases.ts | 35 +++++++++++++++++++++++++++++++++++ tests/eval-quality.test.ts | 22 ++++++++++++++++++++++ tests/rag-eval-cases.test.ts | 18 ++++++++++++++++++ 4 files changed, 88 insertions(+), 2 deletions(-) diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts index 5c253f339..d6b2a61a7 100644 --- a/scripts/eval-quality.ts +++ b/scripts/eval-quality.ts @@ -109,6 +109,16 @@ export function sourceWarningsForRagQualityAnswer( ); } +export function deliveredGroundedAfterSourceGovernancePolicy( + answer: Pick, + warnings: Array<{ severity: string }>, +) { + const shouldUseSourceGovernanceRefusal = + answer.grounded !== false && answer.confidence !== "unsupported" && answer.responseMode !== "evidence_gap"; + if (shouldUseSourceGovernanceRefusal && warnings.some((warning) => warning.severity === "danger")) return false; + return answer.grounded; +} + const qualityThresholds = { retrievalTopKHitRate: 0.8, retrievalDocumentRecallAt5: 0.8, @@ -794,9 +804,10 @@ async function runRagQualityCases(args: { const failures = [...validation.failures]; const sourceWarnings = sourceWarningsForRagQualityAnswer(answer); const sourceDangerWarningCount = sourceWarnings.filter((warning) => warning.severity === "danger").length; + const deliveredGrounded = deliveredGroundedAfterSourceGovernancePolicy(answer, sourceWarnings); failures.push( ...sourceGovernanceDangerFailuresForAnswer({ - grounded: answer.grounded, + grounded: deliveredGrounded, sourceDangerWarningCount, expectsDangerWarning: testCase.expectsSourceDangerWarning, }), @@ -812,7 +823,7 @@ async function runRagQualityCases(args: { missingFiles: validation.expectedCoverage.missingFiles, topFiles: answer.sources.slice(0, 5).map((source) => source.file_name), expectedHit: validation.expectedHit, - grounded: answer.grounded, + grounded: deliveredGrounded, latencyMs: answer.latencyTimings?.total_latency_ms ?? 0, route: answer.routingMode ?? "none", model: answer.modelUsed ?? null, diff --git a/src/lib/rag-eval-cases.ts b/src/lib/rag-eval-cases.ts index b094365db..8a59f9bfd 100644 --- a/src/lib/rag-eval-cases.ts +++ b/src/lib/rag-eval-cases.ts @@ -165,6 +165,40 @@ function uniqueNonEmpty(values: Array) { return Array.from(new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); } +function metadataBoolean(metadata: unknown, keys: string[]) { + if (typeof metadata !== "object" || metadata === null) return false; + return keys.some((key) => (metadata as Record)[key] === true); +} + +function metadataNumber(metadata: unknown, keys: string[]) { + if (typeof metadata !== "object" || metadata === null) return 0; + for (const key of keys) { + const value = (metadata as Record)[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return 0; +} + +function metadataWarnings(metadata: unknown) { + if (typeof metadata !== "object" || metadata === null) return []; + const record = metadata as Record; + const warnings = record.sourceGovernanceWarnings ?? record.source_governance_warnings ?? record.sourceWarnings; + return Array.isArray(warnings) ? warnings : []; +} + +function capturedCaseExpectsSourceDangerWarning(row: CapturedEvalCaseRow) { + if ( + metadataBoolean(row.metadata, ["expectsSourceDangerWarning", "expects_source_danger_warning"]) || + metadataNumber(row.metadata, ["sourceDangerWarningCount", "source_danger_warning_count"]) > 0 + ) { + return true; + } + return metadataWarnings(row.metadata).some( + (warning) => + typeof warning === "object" && warning !== null && (warning as { severity?: unknown }).severity === "danger", + ); +} + function expectedFilesForCapturedCase(row: CapturedEvalCaseRow, rating: "good" | "needs_fixing") { const explicit = uniqueNonEmpty([row.expected_file]); if (explicit.length > 0) return explicit; @@ -193,6 +227,7 @@ export function mapCapturedEvalCase(row: CapturedEvalCaseRow): RagEvalCase { allowedRoutes: unsupportedFeedback ? ["unsupported"] : ["extractive", "fast", "strong"], minCitations: rating === "good" || (feedbackType && !unsupportedFeedback) ? 1 : 0, latencyTargetMs: unsupportedFeedback ? 2000 : rating === "good" ? 5000 : 20000, + expectsSourceDangerWarning: unsupportedFeedback && capturedCaseExpectsSourceDangerWarning(row) ? true : undefined, }; } diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts index 63b932d39..f21919fc8 100644 --- a/tests/eval-quality.test.ts +++ b/tests/eval-quality.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildEvalQualityReport, + deliveredGroundedAfterSourceGovernancePolicy, qualityFailureCategory, renderEvalQualityMarkdown, sourceGovernanceDangerFailuresForAnswer, @@ -265,6 +266,27 @@ describe("eval quality reporting", () => { expect(sourceGovernanceDangerFailuresForAnswer({ grounded: true, sourceDangerWarningCount: 0 })).toEqual([]); }); + it("mirrors API source-governance refusals before delivery accounting", () => { + expect( + deliveredGroundedAfterSourceGovernancePolicy( + { grounded: true, confidence: "high", responseMode: "clinical_pathway" }, + [{ severity: "danger" }], + ), + ).toBe(false); + expect( + deliveredGroundedAfterSourceGovernancePolicy( + { grounded: true, confidence: "high", responseMode: "clinical_pathway" }, + [{ severity: "warning" }], + ), + ).toBe(true); + expect( + deliveredGroundedAfterSourceGovernancePolicy( + { grounded: false, confidence: "unsupported", responseMode: "evidence_gap" }, + [{ severity: "danger" }], + ), + ).toBe(false); + }); + it("flags refusals that drop an expected danger warning, regardless of grounded", () => { // Refusal expected to surface a danger warning but missing it: a // refusal-safety regression, failing even though grounded=false. diff --git a/tests/rag-eval-cases.test.ts b/tests/rag-eval-cases.test.ts index 6ce5d6674..b1da195ec 100644 --- a/tests/rag-eval-cases.test.ts +++ b/tests/rag-eval-cases.test.ts @@ -85,6 +85,24 @@ describe("captured RAG eval cases", () => { }); }); + it("preserves expected danger-warning metadata on unsupported captures", () => { + const testCase = mapCapturedEvalCase({ + ...row, + id: "capture-source-danger", + miss_reason: "source_insufficient", + metadata: { + rating: "needs_fixing", + feedback_type: "source_insufficient", + sourceGovernanceWarnings: [{ severity: "danger", code: "outdated_source" }], + }, + }); + + expect(testCase).toMatchObject({ + supported: false, + expectsSourceDangerWarning: true, + }); + }); + it("maps numeric-error feedback to a source-backed regression case", () => { const testCase = mapCapturedEvalCase({ ...row, From 2b78614ef2adc2a38f4eb5eb721c3bb5b7f5dfdf Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:27:58 +0800 Subject: [PATCH 6/8] fix(eval): flag source-insufficient warning cases --- src/lib/rag-eval-cases.ts | 5 ++++- tests/rag-eval-cases.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lib/rag-eval-cases.ts b/src/lib/rag-eval-cases.ts index 8a59f9bfd..b526fe0c7 100644 --- a/src/lib/rag-eval-cases.ts +++ b/src/lib/rag-eval-cases.ts @@ -227,7 +227,10 @@ export function mapCapturedEvalCase(row: CapturedEvalCaseRow): RagEvalCase { allowedRoutes: unsupportedFeedback ? ["unsupported"] : ["extractive", "fast", "strong"], minCitations: rating === "good" || (feedbackType && !unsupportedFeedback) ? 1 : 0, latencyTargetMs: unsupportedFeedback ? 2000 : rating === "good" ? 5000 : 20000, - expectsSourceDangerWarning: unsupportedFeedback && capturedCaseExpectsSourceDangerWarning(row) ? true : undefined, + expectsSourceDangerWarning: + feedbackType === "source_insufficient" || (unsupportedFeedback && capturedCaseExpectsSourceDangerWarning(row)) + ? true + : undefined, }; } diff --git a/tests/rag-eval-cases.test.ts b/tests/rag-eval-cases.test.ts index b1da195ec..808a0a17e 100644 --- a/tests/rag-eval-cases.test.ts +++ b/tests/rag-eval-cases.test.ts @@ -103,6 +103,20 @@ describe("captured RAG eval cases", () => { }); }); + it("expects danger warnings for source-insufficient captured refusals", () => { + const testCase = mapCapturedEvalCase({ + ...row, + id: "capture-source-insufficient", + miss_reason: "source_insufficient", + metadata: { rating: "needs_fixing", feedback_type: "source_insufficient" }, + }); + + expect(testCase).toMatchObject({ + supported: false, + expectsSourceDangerWarning: true, + }); + }); + it("maps numeric-error feedback to a source-backed regression case", () => { const testCase = mapCapturedEvalCase({ ...row, From 4447ba8f3f4a64557e417866bd0aa0b7c7691a8b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:36:57 +0800 Subject: [PATCH 7/8] fix(eval): detect persisted governance warning strings --- src/lib/rag-eval-cases.ts | 3 ++- tests/rag-eval-cases.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/rag-eval-cases.ts b/src/lib/rag-eval-cases.ts index b526fe0c7..bd5731d07 100644 --- a/src/lib/rag-eval-cases.ts +++ b/src/lib/rag-eval-cases.ts @@ -195,7 +195,8 @@ function capturedCaseExpectsSourceDangerWarning(row: CapturedEvalCaseRow) { } return metadataWarnings(row.metadata).some( (warning) => - typeof warning === "object" && warning !== null && (warning as { severity?: unknown }).severity === "danger", + (typeof warning === "string" && warning.trim().length > 0) || + (typeof warning === "object" && warning !== null && (warning as { severity?: unknown }).severity === "danger"), ); } diff --git a/tests/rag-eval-cases.test.ts b/tests/rag-eval-cases.test.ts index 808a0a17e..d3a5ac61e 100644 --- a/tests/rag-eval-cases.test.ts +++ b/tests/rag-eval-cases.test.ts @@ -103,6 +103,24 @@ describe("captured RAG eval cases", () => { }); }); + it("detects persisted string source-governance warnings on unsupported captures", () => { + const testCase = mapCapturedEvalCase({ + ...row, + id: "capture-source-danger-string", + miss_reason: "unsupported_answer", + metadata: { + rating: "needs_fixing", + feedback_type: "unsupported_answer", + source_governance_warnings: ["Matched documents are not suitable for clinical use yet."], + }, + }); + + expect(testCase).toMatchObject({ + supported: false, + expectsSourceDangerWarning: true, + }); + }); + it("expects danger warnings for source-insufficient captured refusals", () => { const testCase = mapCapturedEvalCase({ ...row, From 23aa990021efad74215416b2234469c410b43009 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:50:01 +0800 Subject: [PATCH 8/8] fix(eval): match only canonical danger messages in captured warnings The persisted-string detection added in 4447ba8f3 treated any non-empty governance-warning string as danger. /api/eval-cases persists all severities as message strings (ClinicalDashboard submits warning.message), so an unsupported capture whose sole warning is non-danger (e.g. review_due or unverified) was flagged expectsSourceDangerWarning=true and then tripped the "expected danger source governance warning missing" gate on a false positive, hard-blocking release. Match only the canonical per-source danger messages instead: - Export OUTDATED_SOURCE_WARNING_MESSAGE / POOR_EXTRACTION_WARNING_MESSAGE and isDangerSourceGovernanceMessage from source-governance.ts, and use the constants in the producer so the matcher can't drift from what is emitted. - rag-eval-cases predicate recovers danger severity from a persisted string via that matcher (objects still use severity directly). weak_evidence danger is intentionally not string-matchable (dynamic message, fallback shared with the non-danger partial-verdict variant). Added a negative test proving a review_due-only capture does not set the flag. Co-Authored-By: Claude Fable 5 --- src/lib/rag-eval-cases.ts | 18 +++++++++++++----- src/lib/source-governance.ts | 24 ++++++++++++++++++++++-- tests/rag-eval-cases.test.ts | 27 +++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/lib/rag-eval-cases.ts b/src/lib/rag-eval-cases.ts index bd5731d07..45a038f80 100644 --- a/src/lib/rag-eval-cases.ts +++ b/src/lib/rag-eval-cases.ts @@ -1,3 +1,4 @@ +import { isDangerSourceGovernanceMessage } from "@/lib/source-governance"; import type { RagAnswer, RagQueryClass } from "@/lib/types"; export type RagEvalCategory = "routine" | "complex" | "unsupported"; @@ -193,11 +194,18 @@ function capturedCaseExpectsSourceDangerWarning(row: CapturedEvalCaseRow) { ) { return true; } - return metadataWarnings(row.metadata).some( - (warning) => - (typeof warning === "string" && warning.trim().length > 0) || - (typeof warning === "object" && warning !== null && (warning as { severity?: unknown }).severity === "danger"), - ); + return metadataWarnings(row.metadata).some((warning) => { + // Object-shaped warnings carry severity directly. + if (typeof warning === "object" && warning !== null) { + return (warning as { severity?: unknown }).severity === "danger"; + } + // UI captures persist governance warnings as plain message strings (severity + // dropped by /api/eval-cases). Match only the canonical danger messages — + // treating any non-empty string as danger would flag captures whose sole + // warning is non-danger (e.g. review_due / unverified) and then trip the + // "expected danger ... missing" gate on a false positive. + return typeof warning === "string" && isDangerSourceGovernanceMessage(warning); + }); } function expectedFilesForCapturedCase(row: CapturedEvalCaseRow, rating: "good" | "needs_fixing") { diff --git a/src/lib/source-governance.ts b/src/lib/source-governance.ts index df9ffab7e..d9ede0a20 100644 --- a/src/lib/source-governance.ts +++ b/src/lib/source-governance.ts @@ -30,6 +30,26 @@ export type GroupedSourceGovernanceWarning = { export const sourceGovernanceRefusalAnswer = "I cannot provide a clinical answer because one or more matched documents are not suitable for clinical use yet. Try a narrower clinical term or scope the search to a current approved document."; +// Canonical per-source danger-severity warning messages. These are the exact +// strings emitted by sourceGovernanceWarnings below AND the strings persisted by +// UI captures (ClinicalDashboard submits warning.message to /api/eval-cases, +// which drops the severity), so a consumer can recover danger severity from a +// persisted message via isDangerSourceGovernanceMessage. weak_evidence danger is +// intentionally excluded: its message is dynamic (relevance.supportReason) and +// its fallback text is shared with the non-danger partial-verdict variant, so it +// cannot be recovered from a message string without false positives. +export const OUTDATED_SOURCE_WARNING_MESSAGE = "One or more supporting sources are marked outdated."; +export const POOR_EXTRACTION_WARNING_MESSAGE = "One or more supporting sources have poor extraction quality."; + +const dangerSourceGovernanceMessages = new Set([ + OUTDATED_SOURCE_WARNING_MESSAGE, + POOR_EXTRACTION_WARNING_MESSAGE, +]); + +export function isDangerSourceGovernanceMessage(message: string) { + return dangerSourceGovernanceMessages.has(message.trim()); +} + const frontendVisibleWarningCodes = new Set([ "outdated_source", "poor_extraction", @@ -72,7 +92,7 @@ export function sourceGovernanceWarnings(args: { pushUnique(warnings, { code: "outdated_source", severity: "danger", - message: "One or more supporting sources are marked outdated.", + message: OUTDATED_SOURCE_WARNING_MESSAGE, document_id, title, }); @@ -100,7 +120,7 @@ export function sourceGovernanceWarnings(args: { pushUnique(warnings, { code: "poor_extraction", severity: "danger", - message: "One or more supporting sources have poor extraction quality.", + message: POOR_EXTRACTION_WARNING_MESSAGE, document_id, title, }); diff --git a/tests/rag-eval-cases.test.ts b/tests/rag-eval-cases.test.ts index d3a5ac61e..0f8433b4d 100644 --- a/tests/rag-eval-cases.test.ts +++ b/tests/rag-eval-cases.test.ts @@ -103,7 +103,10 @@ describe("captured RAG eval cases", () => { }); }); - it("detects persisted string source-governance warnings on unsupported captures", () => { + it("detects persisted danger-message string governance warnings on unsupported captures", () => { + // /api/eval-cases persists governance warnings as plain message strings + // (ClinicalDashboard submits warning.message), so the predicate matches the + // canonical danger message text, not only object-shaped { severity: "danger" }. const testCase = mapCapturedEvalCase({ ...row, id: "capture-source-danger-string", @@ -111,7 +114,10 @@ describe("captured RAG eval cases", () => { metadata: { rating: "needs_fixing", feedback_type: "unsupported_answer", - source_governance_warnings: ["Matched documents are not suitable for clinical use yet."], + source_governance_warnings: [ + "One or more supporting sources have not been locally validated.", + "One or more supporting sources are marked outdated.", + ], }, }); @@ -121,6 +127,23 @@ describe("captured RAG eval cases", () => { }); }); + it("does not expect a danger warning for non-danger string warnings on unsupported captures", () => { + // A review_due-only refusal carries no danger warning; flagging it as + // expecting one would trip the missing-warning gate on a false positive. + const testCase = mapCapturedEvalCase({ + ...row, + id: "capture-string-warning-only", + miss_reason: "unsupported_answer", + metadata: { + rating: "needs_fixing", + feedback_type: "unsupported_answer", + source_governance_warnings: ["One or more supporting sources are due for review."], + }, + }); + + expect(testCase.expectsSourceDangerWarning).toBeUndefined(); + }); + it("expects danger warnings for source-insufficient captured refusals", () => { const testCase = mapCapturedEvalCase({ ...row,