From 3a6f02b106406434c885f67c68e0e776c570fd77 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:40:34 +0800 Subject: [PATCH 01/20] Fix flowchart-next-step retrieval: surface red-zone action evidence The golden case "In the clinical flowchart, what is the next step after red-zone risk?" failed content-recall because the answer chunks (red zone -> escalate / urgent senior review) never reached the top 5. Three compounding causes, smallest fix applied at each level: 1. Candidate generation (root cause): the flowchart query variants "red zone risk flow" and "risk flow review urgent escalation" are fully conjunctive under websearch_to_tsquery and matched 0 and 2 live chunks - they contributed nothing. Replaced with a plain "red zone" variant (13 precise zone-action chunks live) gated on the query mentioning a zone. 2. Fast-path gating: both decideTextFastPath (document_lookup) and the visual_flowchart_risk_gate accepted action-free flowchart pages - the gate satisfied its two term groups across different results and image captions. Flowchart/zone action queries now require zone AND action language on a single top result before skipping structured retrieval (mirrors threshold_action_requires_structured_retrieval). 3. Ranking: riskFlowchartSource only recognised flowchart-worded text, so escalation protocols expressing the flowchart steps as prose took the -0.18 generic penalty while unrelated risk-assessment flowcharts kept the +0.16 boost. Zone+action text now counts as risk-flowchart source evidence. Validation: golden retrieval eval 10/10 (content_recall@5 1.0, up from 0.9667; doc_recall@5 1.0; hit_rate 1.0), latency eval 0 failures (median 1.2s, p90 12.1s), fixed case answers in ~1.0s via fast path with the correct evidence ranked #1. Full vitest 691 passed, typecheck and lint clean. Three new unit tests pin the gates and ranking rule. Co-Authored-By: Claude Opus 4.8 --- src/lib/clinical-search.ts | 12 +++- src/lib/rag.ts | 48 +++++++++++-- tests/retrieval-query-variants.test.ts | 94 +++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index e44323949..9c59c6880 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1312,8 +1312,18 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se queryClass === "document_lookup" && /\b(?:flow\s*chart|flowchart|algorithm|pathway)\b/i.test(query) && /\b(?:risk|red\s*zone|red|next step|step after)\b/i.test(query); + // Zone-action evidence ("red zone ... escalate / urgent review") answers a risk + // flowchart question even when the source never uses the word "flowchart" — + // escalation protocols express the flowchart's decision steps as text. Without + // this, the generic penalty demoted the documents that actually contain the + // red-zone next step while unrelated risk-assessment flowcharts kept the boost. + const riskFlowchartZoneActionSource = + /\b(?:red[\s-]*zones?|coloured? zones?|colored? zones?|zones?)\b/.test(haystack) && + /\b(?:escalat\w*|urgent|review\w*|actions? required)\b/.test(haystack); const riskFlowchartSource = - /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && /\b(?:risk|red zone|red)\b/.test(haystack); + (/\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && + /\b(?:risk|red zone|red)\b/.test(haystack)) || + riskFlowchartZoneActionSource; const riskFlowchartCanonicalTitle = riskFlowchartQuery && /\b(?:flow|flowchart|flow chart|algorithm|pathway|matrix)\b/.test(titleTokenText) && diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 8680f4696..d8dd8c976 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1895,8 +1895,15 @@ export function buildRetrievalQueryVariants( /\b(?:risk|red\s*zone|red|urgent|escalat|next step)\b/i.test(query) ) { addVariant("risk flow"); - addVariant("red zone risk flow"); - addVariant("risk flow review urgent escalation"); + // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" + // and "risk flow review urgent escalation" variants required all terms in one + // chunk and matched 0 and 2 live chunks respectively - they pulled nothing + // into the candidate pool. A plain "red zone" variant retrieves the small, + // precise set of zone-action chunks (escalation protocols, observation and + // response charts) that actually answer red-zone / next-step questions. + if (/\b(?:red[\s-]*zone|zones?)\b/i.test(query)) { + addVariant("red zone"); + } } addVariant(analysis.queryRewrite.searchQuery); @@ -3048,6 +3055,25 @@ export function decideTextFastPath( } if (queryClass === "document_lookup") { + // Flowchart/zone "next step" questions need the action evidence (escalate / + // urgent review / actions required), not just a lexically matching flowchart + // page. Many unrelated policies embed "risk assessment flow chart" appendices + // that outscore the intended zone-action document on text alone, so mirror the + // threshold_action gate: only fast-path when the top candidates already carry + // action language; otherwise fall through to structured/vector retrieval. + const flowchartZoneActionQuery = + /\b(?:flow\s*charts?|flowcharts?|algorithms?|pathways?)\b/i.test(query) && + /\b(?:red[\s-]*zone|next step|step after)\b/i.test(query); + if ( + flowchartZoneActionQuery && + !results.slice(0, 5).some((result) => + /\b(?:escalat\w*|urgent|review\w*|respond\w*|actions?\s+required)\b/i.test( + `${result.section_heading ?? ""} ${(result.section_path ?? []).join(" ")} ${result.retrieval_synopsis ?? ""} ${result.content ?? ""}`, + ), + ) + ) { + return { returnFastPath: false, reason: "flowchart_action_requires_structured_retrieval" }; + } if (directTitleSupport && strongestScore >= 0.32) { return { returnFastPath: true, reason: "direct_title_text_match" }; } @@ -3332,11 +3358,19 @@ export function evaluateEvidenceCoverageGate( sourceImageSatisfied, }; } - if (/\b(?:flow\s*chart|flowchart|red\s*zone|risk matrix)\b/i.test(query)) { - const accepted = - hasVisualUnit || - (hasAnyTerm(evidenceText, /\b(?:flow\s*chart|flowchart|risk|red|zone|matrix)\b/i) && - hasAnyTerm(evidenceText, /\b(?:escalat|urgent|review|action|next step|senior)\b/i)); + if (/\b(?:flow\s*chart|flowchart|red[\s-]*zone|risk matrix)\b/i.test(query)) { + // A flowchart/zone question is only answered when a single top result carries + // BOTH the zone/escalation context AND the action language. Checking the two + // term groups independently across all top-5 evidence (or accepting any visual + // unit) let unrelated risk-assessment flowcharts pass on a generic flowchart + // page plus scattered "action"/"review" words from other candidates. + const accepted = top.some((result) => { + const text = evidenceTextForGate(result); + return ( + /\b(?:red[\s-]*zone|zone|escalation|deteriorat\w+)\b/i.test(text) && + /\b(?:escalat\w*|urgent|review\w*|actions?\s+required)\b/i.test(text) + ); + }); return { accepted, reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index 510960e3f..9418a5da9 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -202,6 +202,74 @@ describe("retrieval query variants", () => { ).toEqual({ returnFastPath: true, reason: "dose_evidence_text_match" }); }); + it("keeps flowchart zone-action fast paths gated on action evidence", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + + // A lexically strong but action-free flowchart page must not short-circuit + // retrieval before the structured/vector layers can surface the zone actions. + expect( + decideTextFastPath( + query, + [ + result({ + content: "Appendix IV: Risk assessment flow chart to identify infection control procedures.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "flowchart_action_requires_structured_retrieval" }); + + expect( + decideTextFastPath( + query, + [ + result({ + content: + "If deteriorating (has any Purple or Red Zone criteria on observation chart), escalate for Senior Clinician Review or call a MET.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + + it("requires zone and action evidence on a single result for the flowchart risk gate", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + + // Zone words on one result and action words on another (or a flowchart image + // caption) must not satisfy the gate - the answer needs both on one result. + expect( + evaluateEvidenceCoverageGate( + query, + [ + result({ content: "Risk assessment flow chart for infection control procedures.", similarity: 0.9 }), + result({ + id: "chunk-2", + content: "Compliance is monitored by review of clinical incidents.", + similarity: 0.8, + }), + ], + "document_lookup", + ), + ).toMatchObject({ accepted: false, reason: "missing_visual_flowchart_risk_evidence" }); + + expect( + evaluateEvidenceCoverageGate( + query, + [ + result({ + content: + "If deteriorating (has any Purple or Red Zone criteria on observation chart), escalate for Senior Clinician Review.", + similarity: 0.9, + }), + ], + "document_lookup", + ), + ).toMatchObject({ accepted: true, reason: "visual_flowchart_risk_gate" }); + }); + it("does not fast-path comparison queries before synthesis retrieval", () => { expect( decideTextFastPath( @@ -363,7 +431,11 @@ describe("retrieval query variants", () => { expect(textQuery).toContain("flowchart"); expect(textQuery).toContain("next"); expect(textQuery).toContain("step"); - expect(variants).toEqual(expect.arrayContaining(["risk flow", "red zone risk flow"])); + // "red zone" replaces the old fully-conjunctive variants ("red zone risk flow", + // "risk flow review urgent escalation") which matched 0/2 live chunks under + // websearch_to_tsquery AND semantics and never contributed candidates. + expect(variants).toEqual(expect.arrayContaining(["risk flow", "red zone"])); + expect(variants).not.toContain("red zone risk flow"); expect(variants.length).toBeLessThanOrEqual(4); }); @@ -519,6 +591,26 @@ describe("retrieval query variants", () => { expect(selected.results[0]?.file_name).toBe("Clinical Risk Flowchart.pdf"); }); + it("treats zone-action escalation text as risk-flowchart evidence even without the word flowchart", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "zone-action", + document_id: "zone-action-doc", + title: "Recognising and Responding to Acute Deterioration", + file_name: "Recognising and Responding to Acute Deterioration.pdf", + content: + "If deteriorating (has any Purple or Red Zone criteria on observation chart), escalate for Senior Clinician Review or call a MET.", + similarity: 0.62, + }), + ]); + + // Escalation protocols express the flowchart's decision steps as text; they + // must not take the generic risk-flowchart penalty for lacking the literal + // word "flowchart". + expect(ranked[0]?.score_explanation?.rawPenalty).toBe(0); + }); + it("redacts retrieval cache keys while preserving query class and variant uniqueness", () => { const baseArgs = { query: "What ANC threshold should stop clozapine?", From 3fc2a84869f0d2f38c9053633f41af8dac3cefe1 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:35:04 +0800 Subject: [PATCH 02/20] Tighten zone-action guards per Codex review - Fast-path guard now requires zone context AND action language on a single top result (was: any action word in top-5), matching the coverage gate, so "review " on an unrelated flowchart can no longer short-circuit structured retrieval. - The injected zone variant follows the colour the query names (was: always "red zone" for any zone mention), so amber/yellow-zone questions no longer pull red-zone chunks into their candidate pool. - riskFlowchartZoneActionSource requires an explicit coloured-zone reference or "zone criteria" (was: bare "zones?"), so generic zone+review chunks keep the intended penalty. - All three sites now share riskZoneContextPattern / riskZoneActionPattern exported from clinical-search to prevent drift. Validation: golden eval 23/23 (all metrics 1.0, flowchart case 1.1s), full vitest 741 passed, typecheck + lint clean. Two new test cases pin the action-without-zone refusal and the colour-matched variant. Co-Authored-By: Claude Opus 4.8 --- src/lib/clinical-search.ts | 13 +++++++-- src/lib/rag.ts | 37 ++++++++++++++------------ tests/retrieval-query-variants.test.ts | 25 +++++++++++++++++ 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 702139cbd..1be9082d9 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -24,6 +24,15 @@ export type RagQueryClassification = { needsSynthesis: boolean; }; +// Shared signals for coloured-zone "next step" retrieval guards (ranking source +// evidence here; text fast-path and coverage gates in rag.ts). The zone context +// deliberately requires an explicit coloured-zone reference or "zone criteria" — +// a bare "zone" (clean zone, time zone, zone 3) plus a common word like "review" +// must not qualify as escalation evidence. +export const riskZoneContextPattern = + /\b(?:(?:red|amber|yellow|orange|purple|green|blue)[\s-]*zones?|colou?red zones?|zone criteria)\b/i; +export const riskZoneActionPattern = /\b(?:escalat\w+|urgent|review\w*|respond\w*|actions?\s+required)\b/i; + export const intentSignalWords = { dosing: [ "dose", @@ -1323,9 +1332,9 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se // escalation protocols express the flowchart's decision steps as text. Without // this, the generic penalty demoted the documents that actually contain the // red-zone next step while unrelated risk-assessment flowcharts kept the boost. + // Both term groups must hit: a bare "zone" or a lone "review" is not evidence. const riskFlowchartZoneActionSource = - /\b(?:red[\s-]*zones?|coloured? zones?|colored? zones?|zones?)\b/.test(haystack) && - /\b(?:escalat\w*|urgent|review\w*|actions? required)\b/.test(haystack); + riskZoneContextPattern.test(haystack) && riskZoneActionPattern.test(haystack); const riskFlowchartSource = (/\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && /\b(?:risk|red zone|red)\b/.test(haystack)) || diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 5a935cc4b..50dfb7438 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -24,6 +24,8 @@ import { hasStructuredThresholdEvidence, normalizedClinicalSearchTokens, rankClinicalResults, + riskZoneActionPattern, + riskZoneContextPattern, } from "@/lib/clinical-search"; import { env, isDemoMode, isLocalNoAuthMode, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; @@ -1941,11 +1943,14 @@ export function buildRetrievalQueryVariants( // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" // and "risk flow review urgent escalation" variants required all terms in one // chunk and matched 0 and 2 live chunks respectively - they pulled nothing - // into the candidate pool. A plain "red zone" variant retrieves the small, + // into the candidate pool. A " zone" variant retrieves the small, // precise set of zone-action chunks (escalation protocols, observation and - // response charts) that actually answer red-zone / next-step questions. - if (/\b(?:red[\s-]*zone|zones?)\b/i.test(query)) { - addVariant("red zone"); + // response charts) that answer zone / next-step questions. Match the zone the + // query actually names so an amber-zone question does not pull red-zone + // chunks into its candidate pool. + const zoneColour = query.match(/\b(red|amber|yellow|orange|purple|green|blue)[\s-]*zones?\b/i)?.[1]; + if (zoneColour) { + addVariant(`${zoneColour.toLowerCase()} zone`); } } addVariant(analysis.queryRewrite.searchQuery); @@ -3157,22 +3162,23 @@ export function decideTextFastPath( } if (queryClass === "document_lookup") { - // Flowchart/zone "next step" questions need the action evidence (escalate / - // urgent review / actions required), not just a lexically matching flowchart + // Flowchart/zone "next step" questions need the zone-action evidence (red + // zone -> escalate / urgent review), not just a lexically matching flowchart // page. Many unrelated policies embed "risk assessment flow chart" appendices // that outscore the intended zone-action document on text alone, so mirror the - // threshold_action gate: only fast-path when the top candidates already carry - // action language; otherwise fall through to structured/vector retrieval. + // threshold_action gate: only fast-path when a single top candidate carries + // BOTH the zone context and the action language (an action word like "review" + // on an unrelated flowchart must not qualify); otherwise fall through to + // structured/vector retrieval. const flowchartZoneActionQuery = /\b(?:flow\s*charts?|flowcharts?|algorithms?|pathways?)\b/i.test(query) && /\b(?:red[\s-]*zone|next step|step after)\b/i.test(query); if ( flowchartZoneActionQuery && - !results.slice(0, 5).some((result) => - /\b(?:escalat\w*|urgent|review\w*|respond\w*|actions?\s+required)\b/i.test( - `${result.section_heading ?? ""} ${(result.section_path ?? []).join(" ")} ${result.retrieval_synopsis ?? ""} ${result.content ?? ""}`, - ), - ) + !results.slice(0, 5).some((result) => { + const text = `${result.section_heading ?? ""} ${(result.section_path ?? []).join(" ")} ${result.retrieval_synopsis ?? ""} ${result.content ?? ""}`; + return riskZoneContextPattern.test(text) && riskZoneActionPattern.test(text); + }) ) { return { returnFastPath: false, reason: "flowchart_action_requires_structured_retrieval" }; } @@ -3468,10 +3474,7 @@ export function evaluateEvidenceCoverageGate( // page plus scattered "action"/"review" words from other candidates. const accepted = top.some((result) => { const text = evidenceTextForGate(result); - return ( - /\b(?:red[\s-]*zone|zone|escalation|deteriorat\w+)\b/i.test(text) && - /\b(?:escalat\w*|urgent|review\w*|actions?\s+required)\b/i.test(text) - ); + return riskZoneContextPattern.test(text) && riskZoneActionPattern.test(text); }); return { accepted, diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index b2199ce26..529aecbb0 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -221,6 +221,21 @@ describe("retrieval query variants", () => { ), ).toEqual({ returnFastPath: false, reason: "flowchart_action_requires_structured_retrieval" }); + // An action word alone (review/urgent) without any coloured-zone context on + // the same result must not satisfy the guard either. + expect( + decideTextFastPath( + query, + [ + result({ + content: "Flowchart: review infection-control procedures before the patient proceeds.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "flowchart_action_requires_structured_retrieval" }); + expect( decideTextFastPath( query, @@ -440,6 +455,16 @@ describe("retrieval query variants", () => { expect(variants.length).toBeLessThanOrEqual(4); }); + it("matches the zone variant to the colour the query names", () => { + const query = "In the clinical flowchart, what is the next step after amber-zone risk?"; + const variants = buildRetrievalQueryVariants(query, analyzeClinicalQuery(query)); + + // An amber-zone question must not pull red-zone chunks into its candidate + // pool; the injected variant follows the colour the query names. + expect(variants).toContain("amber zone"); + expect(variants).not.toContain("red zone"); + }); + it("keeps agitation route queries focused on the canonical agitation and arousal source", () => { const query = "What IM or PO options are listed for agitation?"; const textQuery = buildClinicalTextSearchQuery(query); From d774b843a293de2aacb666100d33d0bbb2c46c53 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:02:05 +0800 Subject: [PATCH 03/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index d6c070d70..252141aec 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1942,8 +1942,7 @@ export function buildRetrievalQueryVariants( addVariant("risk flow"); // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" // and "risk flow review urgent escalation" variants required all terms in one - // chunk and matched 0 and 2 live chunks respectively - they pulled nothing - // into the candidate pool. A " zone" variant retrieves the small, + // chunk and did not reliably contribute candidates to the pool. A " zone" variant retrieves the small, // precise set of zone-action chunks (escalation protocols, observation and // response charts) that answer zone / next-step questions. Match the zone the // query actually names so an amber-zone question does not pull red-zone From 21f65b9a689b99d66a44ee22c362f4d058489809 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:02:13 +0800 Subject: [PATCH 04/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 252141aec..d088c8608 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3475,7 +3475,7 @@ export function evaluateEvidenceCoverageGate( sourceImageSatisfied, }; } - if (/\b(?:flow\s*chart|flowchart|red[\s-]*zone|risk matrix)\b/i.test(query)) { + if (/\b(?:flow\s*chart|flowchart|risk matrix)\b/i.test(query) || riskZoneContextPattern.test(query)) { const accepted = hasRiskFlowchartActionEvidence(results); return { accepted, From 75a7260b563c97a5cc85d2eb9babb028604410a8 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:05:29 +0800 Subject: [PATCH 05/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index d088c8608..d7462ad39 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3164,7 +3164,7 @@ export function decideTextFastPath( // Flowchart/zone "next step" questions need the zone-action evidence (red // zone -> escalate / urgent review), not just a lexically matching flowchart // page; otherwise fall through to structured/vector retrieval. - if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(results)) { + if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(query, results)) { return { returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }; } if (directTitleSupport && strongestScore >= 0.32) { From af706af3834b07e8abea0b12586f3b6f7e566e29 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:05:51 +0800 Subject: [PATCH 06/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index d7462ad39..8e6f1a9a7 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3476,7 +3476,7 @@ export function evaluateEvidenceCoverageGate( }; } if (/\b(?:flow\s*chart|flowchart|risk matrix)\b/i.test(query) || riskZoneContextPattern.test(query)) { - const accepted = hasRiskFlowchartActionEvidence(results); + const accepted = hasRiskFlowchartActionEvidence(query, results); return { accepted, reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", From c5b376e0d871b78c35cfb7aaa72c7138cf241514 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:22 +0800 Subject: [PATCH 07/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 8e6f1a9a7..d7462ad39 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3476,7 +3476,7 @@ export function evaluateEvidenceCoverageGate( }; } if (/\b(?:flow\s*chart|flowchart|risk matrix)\b/i.test(query) || riskZoneContextPattern.test(query)) { - const accepted = hasRiskFlowchartActionEvidence(query, results); + const accepted = hasRiskFlowchartActionEvidence(results); return { accepted, reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", From ccf43a2afcd571b03361072bfa6626302b88bc27 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:32 +0800 Subject: [PATCH 08/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index d7462ad39..d088c8608 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3164,7 +3164,7 @@ export function decideTextFastPath( // Flowchart/zone "next step" questions need the zone-action evidence (red // zone -> escalate / urgent review), not just a lexically matching flowchart // page; otherwise fall through to structured/vector retrieval. - if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(query, results)) { + if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(results)) { return { returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }; } if (directTitleSupport && strongestScore >= 0.32) { From 1f75b504230c519bcd44887f4648df3821be6ee9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:12:00 +0800 Subject: [PATCH 09/20] Address second-round review: colour-aware zone guards - isRiskFlowchartNextStepQuery accepts any coloured-zone query (amber/ yellow/etc), not just risk|red, keeping the guard aligned with the multi-colour variant handling (Copilot). - hasRiskFlowchartActionEvidence now matches the colour the query names (a red-zone question cannot fast-path on an amber-zone chunk) and accepts bare colour tokens on risk-matrix/flowchart visual units, whose content stores the cell colour as "... | Red | escalate ..." (Codex P2 x2). - The coverage-gate zone branch now only catches zone/next-step queries; plain flowchart document lookups fall through to the ordinary title gate instead of being rejected for lacking zone evidence (Codex P2). - Ranking source check mirrors the risk-matrix colour-token rule. Validation: golden eval 23/23 (flowchart case 1.3s), full vitest 780 passed, typecheck + lint clean. Four new test cases pin the colour matching, matrix-cell tokens, and title-gate fallthrough. Co-Authored-By: Claude Opus 4.8 --- src/lib/clinical-search.ts | 8 ++- src/lib/rag.ts | 53 ++++++++++++----- tests/retrieval-query-variants.test.ts | 82 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 2d64aa9a2..bd96c3c1e 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1339,8 +1339,14 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se // this, the generic penalty demoted the documents that actually contain the // red-zone next step while unrelated risk-assessment flowcharts kept the boost. // Both term groups must hit: a bare "zone" or a lone "review" is not evidence. + // Risk-matrix / flowchart visual units store the cell colour as a bare token + // ("... | Red | escalate ..."), so for those units the colour token counts as + // zone context. + const zoneCellUnitEvidence = + ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && + /\b(?:red|amber|yellow|orange|purple|green|blue)\b/.test(haystack); const riskFlowchartZoneActionSource = - riskZoneContextPattern.test(haystack) && riskZoneActionPattern.test(haystack); + (riskZoneContextPattern.test(haystack) || zoneCellUnitEvidence) && riskZoneActionPattern.test(haystack); const riskFlowchartSource = (/\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && /\b(?:risk|red zone|red)\b/.test(haystack)) || diff --git a/src/lib/rag.ts b/src/lib/rag.ts index d6c070d70..279b3aa45 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3165,7 +3165,7 @@ export function decideTextFastPath( // Flowchart/zone "next step" questions need the zone-action evidence (red // zone -> escalate / urgent review), not just a lexically matching flowchart // page; otherwise fall through to structured/vector retrieval. - if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(results)) { + if (isRiskFlowchartNextStepQuery(query) && !hasRiskFlowchartActionEvidence(query, results)) { return { returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }; } if (directTitleSupport && strongestScore >= 0.32) { @@ -3261,27 +3261,46 @@ function hasAnyTerm(text: string, pattern: RegExp) { return pattern.test(text); } +const zoneColourAlternatives = "red|amber|yellow|orange|purple|green|blue"; + +function queriedZoneColour(query: string) { + return query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]?.toLowerCase() ?? null; +} + function isRiskFlowchartNextStepQuery(query: string) { return ( /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && - /\b(?:risk|red[\s-]*zone|red)\b/i.test(query) && + (new RegExp(`\\b(?:risk|(?:${zoneColourAlternatives})[\\s-]*zones?)\\b`, "i").test(query)) && /\b(?:next step|step after|after|action)\b/i.test(query) ); } -function hasRiskFlowchartActionEvidence(results: SearchResult[], limit = 5) { - // A single result must carry BOTH the coloured-zone context and the action - // language (escalate / urgent review): scattering the two term groups across - // different results (or their image captions) let unrelated risk-assessment - // flowcharts pass. Deliberately does NOT require a flowchart word in the - // evidence — the escalation protocols that answer a red-zone question express - // the flowchart's decision steps as prose ("has any Purple or Red Zone - // criteria ... escalate for Senior Clinician Review") without ever saying - // "flowchart". Shared patterns keep this aligned with the ranking source - // check in clinical-search. +function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], limit = 5) { + // A single result must carry BOTH the zone context and the action language + // (escalate / urgent review): scattering the two term groups across different + // results (or their image captions) let unrelated risk-assessment flowcharts + // pass. Deliberately does NOT require a flowchart word in the evidence — the + // escalation protocols that answer a red-zone question express the flowchart's + // decision steps as prose ("has any Purple or Red Zone criteria ... escalate + // for Senior Clinician Review") without ever saying "flowchart". + // + // When the query names a colour, the zone evidence must match that colour (a + // red-zone question must not fast-path on an amber-zone chunk). Risk-matrix / + // flowchart visual units store the cell colour as a bare token + // ("... | Red | escalate ..."), so for those units the colour token alone + // counts as zone context. + const colour = queriedZoneColour(query); + const colourGroup = colour ?? `(?:${zoneColourAlternatives})`; + const zonePhrasePattern = new RegExp( + `\\b${colourGroup}[\\s-]*zones?\\b|\\bcolou?red zones?\\b|\\bzone criteria\\b`, + "i", + ); + const bareColourPattern = new RegExp(`\\b${colourGroup}\\b`, "i"); return results.slice(0, limit).some((result) => { const evidenceText = evidenceTextForGate(result); - return riskZoneContextPattern.test(evidenceText) && riskZoneActionPattern.test(evidenceText); + if (!riskZoneActionPattern.test(evidenceText)) return false; + if (zonePhrasePattern.test(evidenceText)) return true; + return visualEvidenceUnitTypes.has(result.index_unit?.unit_type ?? "") && bareColourPattern.test(evidenceText); }); } @@ -3476,8 +3495,12 @@ export function evaluateEvidenceCoverageGate( sourceImageSatisfied, }; } - if (/\b(?:flow\s*chart|flowchart|red[\s-]*zone|risk matrix)\b/i.test(query)) { - const accepted = hasRiskFlowchartActionEvidence(results); + // Only zone/next-step flowchart questions need the zone-action evidence + // gate; a plain flowchart document lookup ("which procedure flowchart + // covers X?") falls through to the ordinary title gate below so a direct + // title hit is not rejected for lacking zone evidence. + if (isRiskFlowchartNextStepQuery(query)) { + const accepted = hasRiskFlowchartActionEvidence(query, results); return { accepted, reason: accepted ? "visual_flowchart_risk_gate" : "missing_visual_flowchart_risk_evidence", diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index edce94b47..b17c88447 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -300,6 +300,68 @@ describe("retrieval query variants", () => { ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); }); + it("matches the queried zone colour before fast-pathing", () => { + // A red-zone question must not fast-path on an amber-zone action chunk. + expect( + decideTextFastPath( + "In the clinical flowchart, what is the next step after red-zone risk?", + [ + result({ + content: "If the patient reaches the Amber Zone, escalate for urgent senior review.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + + // ...and an amber-zone question (no literal "risk"/"red") still triggers the + // guard and is satisfied by amber-zone action evidence. + expect( + decideTextFastPath( + "In the clinical flowchart, what is the next step after the amber zone?", + [ + result({ + content: "If the patient reaches the Amber Zone, escalate for urgent senior review.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + + it("accepts risk-matrix cell colour tokens as zone context", () => { + // risk_matrix_cell units store the cell colour as a bare token + // ("... | Red | escalate ..."), not as the phrase "red zone". + expect( + decideTextFastPath( + "In the risk matrix flowchart, what action is shown after red-zone risk?", + [ + result({ + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + similarity: 0.82, + index_unit: { + id: "unit-rm", + unit_type: "risk_matrix_cell", + title: "Physical aggression / Recent incident: Red", + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + source_chunk_id: "chunk-1", + source_image_id: "image-1", + page_start: 1, + page_end: 1, + heading_path: ["Risk matrix"], + normalized_terms: ["red", "risk matrix"], + quality_score: 0.9, + extraction_mode: "model_heavy", + }, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + it("requires zone and action evidence on a single result for the flowchart risk gate", () => { const query = "In the clinical flowchart, what is the next step after red-zone risk?"; @@ -335,6 +397,26 @@ describe("retrieval query variants", () => { ).toMatchObject({ accepted: true, reason: "visual_flowchart_risk_gate" }); }); + it("routes plain flowchart document lookups through the ordinary title gate", () => { + // A flowchart mention without zone / next-step intent must not be forced + // through the zone-action gate; a direct title hit uses the title gate. + expect( + evaluateEvidenceCoverageGate( + "Which procedure flowchart covers ECT team coordination?", + [ + result({ + title: "ECT Team Coordination Procedure Flowchart", + file_name: "ect-team-coordination-flowchart.pdf", + content: "Procedure flowchart covering ECT team coordination responsibilities.", + similarity: 0.72, + match_explanation: { titleHit: true, reasons: ["title"] }, + }), + ], + "document_lookup", + ), + ).toMatchObject({ strategy: "document_lookup_fast_path", reason: "document_title_evidence_gate" }); + }); + it("does not fast-path comparison queries before synthesis retrieval", () => { expect( decideTextFastPath( From ecb2a2a71b1814ae6d8126cacdb2530742e3fd66 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:23:09 +0800 Subject: [PATCH 10/20] Address third-round review: real action instructions only - riskZoneActionPattern no longer accepts bare "review": document boilerplate ("Review date: ...", "reviewed by ...") satisfied the zone-action guard. "review" now only counts attached to a clinical role or urgency (senior clinician review, urgent/medical officer review), alongside escalate/urgent/respond/actions-required/MET. - For next-step/action queries, the riskFlowchartSource ranking boost requires action evidence on the same result: a flowchart page that merely names red-zone risk no longer takes the +0.16 boost or dodges the -0.18 generic penalty. - (The "guard misses amber zones" comment was already fixed in 1f75b50 - isRiskFlowchartNextStepQuery accepts all coloured zones.) Validation: golden eval 23/23 (flowchart case 0.9s), full vitest 781 passed, typecheck + lint clean. Two new test cases pin the boilerplate refusal and the action-free flowchart penalty. Co-Authored-By: Claude Opus 4.8 --- src/lib/clinical-search.ts | 25 ++++++++++++++----- tests/retrieval-query-variants.test.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index bd96c3c1e..620520b5d 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -28,10 +28,15 @@ export type RagQueryClassification = { // evidence here; text fast-path and coverage gates in rag.ts). The zone context // deliberately requires an explicit coloured-zone reference or "zone criteria" — // a bare "zone" (clean zone, time zone, zone 3) plus a common word like "review" -// must not qualify as escalation evidence. +// must not qualify as escalation evidence. The action group likewise requires a +// real clinical instruction: bare "review\w*" would let document boilerplate +// ("Review date: ...", "reviewed by ...") satisfy the guard, so "review" only +// counts when attached to a clinical role or urgency (senior clinician review, +// urgent medical officer review) or an escalation/MET phrase. export const riskZoneContextPattern = /\b(?:(?:red|amber|yellow|orange|purple|green|blue)[\s-]*zones?|colou?red zones?|zone criteria)\b/i; -export const riskZoneActionPattern = /\b(?:escalat\w+|urgent|review\w*|respond\w*|actions?\s+required)\b/i; +export const riskZoneActionPattern = + /\b(?:escalat\w+|urgent\w*|respond\w*|actions?\s+required|call(?:ing)?\s+(?:a\s+)?met\b|met\s+call|(?:senior|medical|clinician|clinical|nursing|officer)\s+(?:\w+\s+){0,2}review\w*)\b/i; export const intentSignalWords = { dosing: [ @@ -1347,10 +1352,18 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se /\b(?:red|amber|yellow|orange|purple|green|blue)\b/.test(haystack); const riskFlowchartZoneActionSource = (riskZoneContextPattern.test(haystack) || zoneCellUnitEvidence) && riskZoneActionPattern.test(haystack); - const riskFlowchartSource = - (/\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && - /\b(?:risk|red zone|red)\b/.test(haystack)) || - riskFlowchartZoneActionSource; + const riskFlowchartLexicalSource = + /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && + /\b(?:risk|red zone|red)\b/.test(haystack); + // For next-step/action questions, a flowchart page that names the risk but + // carries no action instruction is exactly the false positive the retrieval + // gates defer past — it must not take the risk-flowchart boost (nor dodge the + // generic penalty) on lexical grounds alone; the action evidence must be on + // the same result. + const nextStepActionQuery = /\b(?:next step|step after|action)\b/i.test(query); + const riskFlowchartSource = nextStepActionQuery + ? riskFlowchartZoneActionSource || (riskFlowchartLexicalSource && riskZoneActionPattern.test(haystack)) + : riskFlowchartLexicalSource || riskFlowchartZoneActionSource; const riskFlowchartCanonicalTitle = riskFlowchartQuery && /\b(?:flow|flowchart|flow chart|algorithm|pathway|matrix)\b/.test(titleTokenText) && diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index b17c88447..b6f4bcaf7 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -285,6 +285,21 @@ describe("retrieval query variants", () => { ), ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + // Document "review" boilerplate (review date / reviewed by) on a zone chunk + // is not an action instruction and must not satisfy the guard. + expect( + decideTextFastPath( + query, + [ + result({ + content: "Red Zone criteria table. Review date: March 2026. Reviewed by the policy committee.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + expect( decideTextFastPath( query, @@ -797,6 +812,24 @@ describe("retrieval query variants", () => { expect(selected.results[0]?.file_name).toBe("Clinical Risk Flowchart.pdf"); }); + it("penalizes action-free risk flowcharts for next-step queries", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "action-free-flowchart", + document_id: "action-free-doc", + title: "Infection Control Policy", + file_name: "Infection Control Policy.pdf", + content: "Risk assessment flow chart covering red-zone procedural risk categories.", + similarity: 0.62, + }), + ]); + + // Naming the risk without any action instruction must not earn the + // risk-flowchart boost (or dodge the generic penalty) for a next-step query. + expect(ranked[0]?.score_explanation?.rawPenalty).toBeLessThanOrEqual(-0.18); + }); + it("treats zone-action escalation text as risk-flowchart evidence even without the word flowchart", () => { const query = "In the clinical flowchart, what is the next step after red-zone risk?"; const ranked = rankClinicalResults(query, [ From 32ee87ee99786357ce9f016aa20998f58fa965de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:30:04 +0000 Subject: [PATCH 11/20] fix: align riskFlowchartQuery zone detection with riskZoneContextPattern --- src/lib/clinical-search.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 13d341f47..3e4ac8383 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1354,7 +1354,7 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se const riskFlowchartQuery = queryClass === "document_lookup" && /\b(?:flow\s*chart|flowchart|algorithm|pathway)\b/i.test(query) && - /\b(?:risk|red\s*zone|red|next step|step after)\b/i.test(query); + (/\b(?:risk|next step|step after)\b/i.test(query) || riskZoneContextPattern.test(query)); // Zone-action evidence ("red zone ... escalate / urgent review") answers a risk // flowchart question even when the source never uses the word "flowchart" — // escalation protocols express the flowchart's decision steps as text. Without From edce2067f2d759da52323325e37246e34c3511d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:30:12 +0000 Subject: [PATCH 12/20] Fix zone-action guard, action-free boost, and action pattern for PR review - riskZoneActionPattern: replace bare review\w* with qualified clinical review phrases (senior/immediate/clinical/medical + optional role word + review) so "review date" / "reviewed by" metadata no longer passes the red-zone action guard - riskFlowchartSource: add riskFlowchartActionQuery guard so queries asking for a next step/action can only boost results that carry both zone context and action evidence (riskFlowchartZoneActionSource); the generic flowchart+risk/red match no longer counts for next-step queries - isRiskFlowchartNextStepQuery: replace inline zone regex with the shared riskZoneContextPattern (imported from clinical-search), aligning the fast-path guard with the coverage gate and adding zone-criteria / coloured- zones support; remove bare "risk" from zone check so only explicit zone references trigger the guard --- package-lock.json | 24 ------------------------ src/lib/clinical-search.ts | 11 +++++++++-- src/lib/rag.ts | 3 ++- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5fc038c01..6e6237c7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1832,9 +1832,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1851,9 +1848,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1870,9 +1864,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1889,9 +1880,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2519,9 +2507,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2539,9 +2524,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2559,9 +2541,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2579,9 +2558,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index e7b7b84c6..44db1f01e 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -31,7 +31,8 @@ export type RagQueryClassification = { // must not qualify as escalation evidence. export const riskZoneContextPattern = /\b(?:(?:red|amber|yellow|orange|purple|green|blue)[\s-]*zones?|colou?red zones?|zone criteria)\b/i; -export const riskZoneActionPattern = /\b(?:escalat\w+|urgent|review\w*|respond\w*|actions?\s+required)\b/i; +export const riskZoneActionPattern = + /\b(?:escalat\w+|urgent|respond\w*|actions?\s+required|(?:senior|immediate|clinical|medical)\s+(?:clinician\s+|specialist\s+|nurse\s+)?review\w*)\b/i; export const intentSignalWords = { dosing: [ @@ -1364,8 +1365,14 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se /\b(?:red|amber|yellow|orange|purple|green|blue)\b/.test(haystack); const riskFlowchartZoneActionSource = (riskZoneContextPattern.test(haystack) || zoneCellUnitEvidence) && riskZoneActionPattern.test(haystack); + // For queries that ask for a next step / action, the generic "flowchart + risk/red" + // match is not sufficient evidence — only zone-action evidence (same result carries + // both a coloured-zone reference and an action term) qualifies for the boost. + const riskFlowchartActionQuery = + riskFlowchartQuery && /\b(?:next step|step after|action)\b/i.test(query); const riskFlowchartSource = - (/\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && + (!riskFlowchartActionQuery && + /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && /\b(?:risk|red zone|red)\b/.test(haystack)) || riskFlowchartZoneActionSource; const riskFlowchartCanonicalTitle = diff --git a/src/lib/rag.ts b/src/lib/rag.ts index de2746553..59a296265 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -25,6 +25,7 @@ import { normalizedClinicalSearchTokens, rankClinicalResults, riskZoneActionPattern, + riskZoneContextPattern, } from "@/lib/clinical-search"; import { env, isDemoMode, isLocalNoAuthMode, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; @@ -3289,7 +3290,7 @@ function queriedZoneColour(query: string) { function isRiskFlowchartNextStepQuery(query: string) { return ( /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && - (new RegExp(`\\b(?:risk|(?:${zoneColourAlternatives})[\\s-]*zones?)\\b`, "i").test(query)) && + riskZoneContextPattern.test(query) && /\b(?:next step|step after|after|action)\b/i.test(query) ); } From 74368559bc657d5ea2fddb55636fc2ca18d15287 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:31:08 +0800 Subject: [PATCH 13/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index de2746553..c45869b44 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1967,7 +1967,7 @@ export function buildRetrievalQueryVariants( // response charts) that answer zone / next-step questions. Match the zone the // query actually names so an amber-zone question does not pull red-zone // chunks into its candidate pool. - const zoneColour = query.match(/\b(red|amber|yellow|orange|purple|green|blue)[\s-]*zones?\b/i)?.[1]; + const zoneColour = query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]; if (zoneColour) { addVariant(`${zoneColour.toLowerCase()} zone`); } From 1b7ccabf14d2de56dea189ed4c204eb1f8b84ddb Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:36:06 +0800 Subject: [PATCH 14/20] Address fourth-round review: colour-scoped ranking, shared zone helpers - The riskFlowchartZoneActionSource ranking boost is now scoped to the colour the query names (via shared zoneContextPatternsForQuery), so a red-zone question is not boosted by an amber cell's action evidence. - The zone variant block also fires for risk-matrix wording ("what action is shown for the risk matrix red zone?"), pulling the bare colour-token risk_matrix_cell chunks into the candidate pool. - zoneColourAlternatives / queriedZoneColour / zoneContextPatternsForQuery now live once in clinical-search and are imported by rag.ts, removing the duplicated colour lists Copilot flagged. Validation: golden eval 23/23 (flowchart case 0.9s), full vitest 795 passed, typecheck + lint clean. Two new tests pin the risk-matrix variant and the cross-colour boost refusal. Co-Authored-By: Claude Opus 4.8 --- src/lib/clinical-search.ts | 36 ++++++++++++++++++++---- src/lib/rag.ts | 39 +++++++++----------------- tests/retrieval-query-variants.test.ts | 28 ++++++++++++++++++ 3 files changed, 73 insertions(+), 30 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 13d341f47..0c2a8f242 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -33,11 +33,34 @@ export type RagQueryClassification = { // ("Review date: ...", "reviewed by ...") satisfy the guard, so "review" only // counts when attached to a clinical role or urgency (senior clinician review, // urgent medical officer review) or an escalation/MET phrase. -export const riskZoneContextPattern = - /\b(?:(?:red|amber|yellow|orange|purple|green|blue)[\s-]*zones?|colou?red zones?|zone criteria)\b/i; +export const zoneColourAlternatives = "red|amber|yellow|orange|purple|green|blue"; +export const riskZoneContextPattern = new RegExp( + `\\b(?:(?:${zoneColourAlternatives})[\\s-]*zones?|colou?red zones?|zone criteria)\\b`, + "i", +); export const riskZoneActionPattern = /\b(?:escalat\w+|urgent\w*|respond\w*|actions?\s+required|call(?:ing)?\s+(?:a\s+)?met\b|met\s+call|(?:senior|medical|clinician|clinical|nursing|officer)\s+(?:\w+\s+){0,2}review\w*)\b/i; +// The zone colour a query names, if any ("red-zone risk" -> "red"). +export function queriedZoneColour(query: string) { + return query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]?.toLowerCase() ?? null; +} + +// Zone-context patterns scoped to the colour the query names (falling back to +// any colour). `zonePhrasePattern` matches explicit zone phrases; `bareColourPattern` +// is for risk-matrix / flowchart visual units, which store the cell colour as a +// bare token ("... | Red | escalate ..."). Shared by the fast-path guard and +// coverage gate in rag.ts and the ranking source check here so a red-zone +// question is never satisfied (or boosted) by another colour's action evidence. +export function zoneContextPatternsForQuery(query: string) { + const colour = queriedZoneColour(query); + const colourGroup = colour ?? `(?:${zoneColourAlternatives})`; + return { + zonePhrasePattern: new RegExp(`\\b${colourGroup}[\\s-]*zones?\\b|\\bcolou?red zones?\\b|\\bzone criteria\\b`, "i"), + bareColourPattern: new RegExp(`\\b${colourGroup}\\b`, "i"), + }; +} + export const intentSignalWords = { dosing: [ "dose", @@ -1361,14 +1384,17 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se // this, the generic penalty demoted the documents that actually contain the // red-zone next step while unrelated risk-assessment flowcharts kept the boost. // Both term groups must hit: a bare "zone" or a lone "review" is not evidence. - // Risk-matrix / flowchart visual units store the cell colour as a bare token + // The patterns are scoped to the colour the query names, so a red-zone + // question is not boosted by an amber-zone cell's action; risk-matrix / + // flowchart visual units store the cell colour as a bare token // ("... | Red | escalate ..."), so for those units the colour token counts as // zone context. + const zonePatterns = zoneContextPatternsForQuery(query); const zoneCellUnitEvidence = ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && - /\b(?:red|amber|yellow|orange|purple|green|blue)\b/.test(haystack); + zonePatterns.bareColourPattern.test(haystack); const riskFlowchartZoneActionSource = - (riskZoneContextPattern.test(haystack) || zoneCellUnitEvidence) && riskZoneActionPattern.test(haystack); + (zonePatterns.zonePhrasePattern.test(haystack) || zoneCellUnitEvidence) && riskZoneActionPattern.test(haystack); const riskFlowchartLexicalSource = /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && /\b(?:risk|red zone|red)\b/.test(haystack); diff --git a/src/lib/rag.ts b/src/lib/rag.ts index de2746553..72886e330 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -24,7 +24,10 @@ import { hasStructuredThresholdEvidence, normalizedClinicalSearchTokens, rankClinicalResults, + queriedZoneColour, riskZoneActionPattern, + zoneColourAlternatives, + zoneContextPatternsForQuery, } from "@/lib/clinical-search"; import { env, isDemoMode, isLocalNoAuthMode, requestedOpenAIAnswerModels } from "@/lib/env"; import { logger } from "@/lib/logger"; @@ -1956,7 +1959,7 @@ export function buildRetrievalQueryVariants( addVariant("admission discharge"); } if ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway)\b/i.test(query) && + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && /\b(?:risk|red\s*zone|red|urgent|escalat|next step)\b/i.test(query) ) { addVariant("risk flow"); @@ -1964,12 +1967,12 @@ export function buildRetrievalQueryVariants( // and "risk flow review urgent escalation" variants required all terms in one // chunk and did not reliably contribute candidates to the pool. A " zone" variant retrieves the small, // precise set of zone-action chunks (escalation protocols, observation and - // response charts) that answer zone / next-step questions. Match the zone the - // query actually names so an amber-zone question does not pull red-zone - // chunks into its candidate pool. - const zoneColour = query.match(/\b(red|amber|yellow|orange|purple|green|blue)[\s-]*zones?\b/i)?.[1]; + // response charts, risk-matrix cells) that answer zone / next-step questions. + // Match the zone the query actually names so an amber-zone question does not + // pull red-zone chunks into its candidate pool. + const zoneColour = queriedZoneColour(query); if (zoneColour) { - addVariant(`${zoneColour.toLowerCase()} zone`); + addVariant(`${zoneColour} zone`); } } addVariant(analysis.queryRewrite.searchQuery); @@ -3280,16 +3283,10 @@ function hasAnyTerm(text: string, pattern: RegExp) { return pattern.test(text); } -const zoneColourAlternatives = "red|amber|yellow|orange|purple|green|blue"; - -function queriedZoneColour(query: string) { - return query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]?.toLowerCase() ?? null; -} - function isRiskFlowchartNextStepQuery(query: string) { return ( /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && - (new RegExp(`\\b(?:risk|(?:${zoneColourAlternatives})[\\s-]*zones?)\\b`, "i").test(query)) && + new RegExp(`\\b(?:risk|(?:${zoneColourAlternatives})[\\s-]*zones?)\\b`, "i").test(query) && /\b(?:next step|step after|after|action)\b/i.test(query) ); } @@ -3303,18 +3300,10 @@ function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], // decision steps as prose ("has any Purple or Red Zone criteria ... escalate // for Senior Clinician Review") without ever saying "flowchart". // - // When the query names a colour, the zone evidence must match that colour (a - // red-zone question must not fast-path on an amber-zone chunk). Risk-matrix / - // flowchart visual units store the cell colour as a bare token - // ("... | Red | escalate ..."), so for those units the colour token alone - // counts as zone context. - const colour = queriedZoneColour(query); - const colourGroup = colour ?? `(?:${zoneColourAlternatives})`; - const zonePhrasePattern = new RegExp( - `\\b${colourGroup}[\\s-]*zones?\\b|\\bcolou?red zones?\\b|\\bzone criteria\\b`, - "i", - ); - const bareColourPattern = new RegExp(`\\b${colourGroup}\\b`, "i"); + // The shared patterns are scoped to the colour the query names (a red-zone + // question must not fast-path on an amber-zone chunk); for risk-matrix / + // flowchart visual units the bare cell colour token counts as zone context. + const { zonePhrasePattern, bareColourPattern } = zoneContextPatternsForQuery(query); return results.slice(0, limit).some((result) => { const evidenceText = evidenceTextForGate(result); if (!riskZoneActionPattern.test(evidenceText)) return false; diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index b6f4bcaf7..821963782 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -660,6 +660,16 @@ describe("retrieval query variants", () => { expect(variants).not.toContain("red zone"); }); + it("injects the zone variant for risk-matrix wording too", () => { + const query = "What action is shown for the risk matrix red zone?"; + const variants = buildRetrievalQueryVariants(query, analyzeClinicalQuery(query)); + + // Risk-matrix questions hit the same zone-action evidence (risk_matrix_cell + // units store the colour as a bare token), so they need the precise + // " zone" variant just like flowchart wording does. + expect(variants).toContain("red zone"); + }); + it("keeps agitation route queries focused on the canonical agitation and arousal source", () => { const query = "What IM or PO options are listed for agitation?"; const textQuery = buildClinicalTextSearchQuery(query); @@ -812,6 +822,24 @@ describe("retrieval query variants", () => { expect(selected.results[0]?.file_name).toBe("Clinical Risk Flowchart.pdf"); }); + it("does not boost another colour's zone action for a colour-specific query", () => { + const query = "In the clinical flowchart, what is the next step after red-zone risk?"; + const ranked = rankClinicalResults(query, [ + result({ + id: "amber-zone-action", + document_id: "amber-zone-doc", + title: "Deterioration Response Guideline", + file_name: "Deterioration Response Guideline.pdf", + content: "If the patient reaches the Amber Zone, escalate for urgent senior review.", + similarity: 0.62, + }), + ]); + + // Amber-zone action evidence must not take the risk-flowchart boost (or + // dodge the generic penalty) for a red-zone question. + expect(ranked[0]?.score_explanation?.rawPenalty).toBeLessThanOrEqual(-0.18); + }); + it("penalizes action-free risk flowcharts for next-step queries", () => { const query = "In the clinical flowchart, what is the next step after red-zone risk?"; const ranked = rankClinicalResults(query, [ From 44d2f04b5da6f89482402bb3a16becaf62a01301 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:38:52 +0800 Subject: [PATCH 15/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/clinical-search.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lib/clinical-search.ts b/src/lib/clinical-search.ts index 90b2ef316..349e035ee 100644 --- a/src/lib/clinical-search.ts +++ b/src/lib/clinical-search.ts @@ -1364,14 +1364,19 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se // Risk-matrix / flowchart visual units store the cell colour as a bare token // ("... | Red | escalate ..."), so for those units the colour token counts as // zone context. + const indexUnitText = result.index_unit + ? `${result.index_unit.title} ${result.index_unit.content}`.toLowerCase() + : ""; + const riskFlowchartEvidenceText = `${haystack} ${indexUnitText}`; const zoneCellUnitEvidence = ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && - /\b(?:red|amber|yellow|orange|purple|green|blue)\b/.test(haystack); + /\b(?:red|amber|yellow|orange|purple|green|blue)\b/.test(riskFlowchartEvidenceText); const riskFlowchartZoneActionSource = - (riskZoneContextPattern.test(haystack) || zoneCellUnitEvidence) && riskZoneActionPattern.test(haystack); + (riskZoneContextPattern.test(riskFlowchartEvidenceText) || zoneCellUnitEvidence) && + riskZoneActionPattern.test(riskFlowchartEvidenceText); const riskFlowchartLexicalSource = - /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(haystack) && - /\b(?:risk|red zone|red)\b/.test(haystack); + /\b(?:flowchart|flow chart|flow|algorithm|pathway|matrix)\b/.test(riskFlowchartEvidenceText) && + /\b(?:risk|red zone|red)\b/.test(riskFlowchartEvidenceText); // For next-step/action questions, a flowchart page that names the risk but // carries no action instruction is exactly the false positive the retrieval // gates defer past — it must not take the risk-flowchart boost (nor dodge the @@ -1379,7 +1384,7 @@ export function clinicalRankExplanation(query: string, result: SearchResult): Se // the same result. const nextStepActionQuery = /\b(?:next step|step after|action)\b/i.test(query); const riskFlowchartSource = nextStepActionQuery - ? riskFlowchartZoneActionSource || (riskFlowchartLexicalSource && riskZoneActionPattern.test(haystack)) + ? riskFlowchartZoneActionSource || (riskFlowchartLexicalSource && riskZoneActionPattern.test(riskFlowchartEvidenceText)) : riskFlowchartLexicalSource || riskFlowchartZoneActionSource; const riskFlowchartCanonicalTitle = riskFlowchartQuery && From 89ed5286d545e91bb66b8e8a3d8b789b30e1d26c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:41:54 +0000 Subject: [PATCH 16/20] fix: include risk-matrix in zone-colour variant injection in buildRetrievalQueryVariants --- src/lib/rag.ts | 2 +- tests/retrieval-query-variants.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 48ef26aa5..1fd525515 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1957,7 +1957,7 @@ export function buildRetrievalQueryVariants( addVariant("admission discharge"); } if ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway)\b/i.test(query) && + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && /\b(?:risk|red\s*zone|red|urgent|escalat|next step)\b/i.test(query) ) { addVariant("risk flow"); diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index b6f4bcaf7..559467aba 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -660,6 +660,16 @@ describe("retrieval query variants", () => { expect(variants).not.toContain("red zone"); }); + it("injects zone-colour variant for risk-matrix queries without a flowchart token", () => { + // A query phrased as "risk matrix" (no "flowchart"/"algorithm"/"pathway") must + // still receive the zone-colour variant so recall is not regressed for this + // class of next-step question. + const query = "In the risk matrix, what is the next step after the red zone?"; + const variants = buildRetrievalQueryVariants(query, analyzeClinicalQuery(query)); + + expect(variants).toEqual(expect.arrayContaining(["risk flow", "red zone"])); + }); + it("keeps agitation route queries focused on the canonical agitation and arousal source", () => { const query = "What IM or PO options are listed for agitation?"; const textQuery = buildClinicalTextSearchQuery(query); From bb4b21eda9a692b34a7c2cb950c4e83324dc7553 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:42:55 +0800 Subject: [PATCH 17/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 1fd525515..8e7cf79c8 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3320,7 +3320,7 @@ function hasRiskFlowchartActionEvidence(query: string, results: SearchResult[], const evidenceText = evidenceTextForGate(result); if (!riskZoneActionPattern.test(evidenceText)) return false; if (zonePhrasePattern.test(evidenceText)) return true; - return visualEvidenceUnitTypes.has(result.index_unit?.unit_type ?? "") && bareColourPattern.test(evidenceText); + return ["risk_matrix_cell", "flowchart_step", "diagram_decision"].includes(result.index_unit?.unit_type ?? "") && bareColourPattern.test(evidenceText); }); } From dffb53d5ff6ecb0f70d19cedb2e9ecd1c36bbc89 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:43:05 +0800 Subject: [PATCH 18/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/lib/rag.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 8e7cf79c8..2d8c4337a 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -1963,9 +1963,9 @@ export function buildRetrievalQueryVariants( addVariant("risk flow"); // websearch_to_tsquery ANDs every term, so the previous "red zone risk flow" // and "risk flow review urgent escalation" variants required all terms in one - // chunk and did not reliably contribute candidates to the pool. A " zone" variant retrieves the small, - // precise set of zone-action chunks (escalation protocols, observation and - // response charts) that answer zone / next-step questions. Match the zone the + // chunk and did not reliably contribute candidates to the pool. + // A " zone" variant retrieves the small, precise set of zone-action chunks + // (escalation protocols, observation and response charts) that answer zone / next-step questions. Match the zone the // query actually names so an amber-zone question does not pull red-zone // chunks into its candidate pool. const zoneColour = query.match(new RegExp(`\\b(${zoneColourAlternatives})[\\s-]*zones?\\b`, "i"))?.[1]; From 54b7964aa5dded1ed145e8a8dc90b4dd4ecde993 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:51:12 +0000 Subject: [PATCH 19/20] fix risk-matrix next-step guard --- src/lib/rag.ts | 2 +- tests/retrieval-query-variants.test.ts | 29 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 286b32cd3..ce6fbaf59 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3285,7 +3285,7 @@ function hasAnyTerm(text: string, pattern: RegExp) { function isRiskFlowchartNextStepQuery(query: string) { return ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && riskZoneContextPattern.test(query) && /\b(?:next step|step after|after|action)\b/i.test(query) ); diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index b03dec6a7..a85fd643a 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -377,6 +377,35 @@ describe("retrieval query variants", () => { ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); }); + it("treats hyphenated risk-matrix queries as flowchart next-step lookups", () => { + expect( + decideTextFastPath( + "In the risk-matrix flowchart, what action is shown after red-zone risk?", + [ + result({ + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + similarity: 0.82, + index_unit: { + id: "unit-rm-hyphen", + unit_type: "risk_matrix_cell", + title: "Physical aggression / Recent incident: Red", + content: "Aggression risk matrix | Physical aggression | Recent incident | Red | Escalate to senior clinician urgently", + source_chunk_id: "chunk-1", + source_image_id: "image-1", + page_start: 1, + page_end: 1, + heading_path: ["Risk matrix"], + normalized_terms: ["red", "risk matrix"], + quality_score: 0.9, + extraction_mode: "model_heavy", + }, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); + }); + it("requires zone and action evidence on a single result for the flowchart risk gate", () => { const query = "In the clinical flowchart, what is the next step after red-zone risk?"; From c2f058aa7c01f439915713f5e3419f3373d30d3d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:53:10 +0800 Subject: [PATCH 20/20] Accept hyphenated risk-matrix in the zone-action query gate isRiskFlowchartNextStepQuery now matches "risk-matrix" like the variant block already does, so hyphenated phrasings get the same action-evidence guard and coverage gate. Test pins the hyphenated form. Co-Authored-By: Claude Opus 4.8 --- src/lib/rag.ts | 2 +- tests/retrieval-query-variants.test.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/lib/rag.ts b/src/lib/rag.ts index 286b32cd3..ce6fbaf59 100644 --- a/src/lib/rag.ts +++ b/src/lib/rag.ts @@ -3285,7 +3285,7 @@ function hasAnyTerm(text: string, pattern: RegExp) { function isRiskFlowchartNextStepQuery(query: string) { return ( - /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk matrix)\b/i.test(query) && + /\b(?:flow\s*chart|flowchart|algorithm|pathway|risk[\s-]*matrix)\b/i.test(query) && riskZoneContextPattern.test(query) && /\b(?:next step|step after|after|action)\b/i.test(query) ); diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts index b03dec6a7..705093da6 100644 --- a/tests/retrieval-query-variants.test.ts +++ b/tests/retrieval-query-variants.test.ts @@ -315,6 +315,22 @@ describe("retrieval query variants", () => { ).toEqual({ returnFastPath: true, reason: "strong_document_text_score" }); }); + it("applies the zone-action guard to hyphenated risk-matrix queries", () => { + // "risk-matrix" phrasing must trigger the same guard as "risk matrix". + expect( + decideTextFastPath( + "In the risk-matrix, what is the next step after the red zone?", + [ + result({ + content: "Risk-matrix overview of procedural exposure categories.", + similarity: 0.82, + }), + ], + "document_lookup", + ), + ).toEqual({ returnFastPath: false, reason: "risk_flowchart_requires_action_evidence" }); + }); + it("matches the queried zone colour before fast-pathing", () => { // A red-zone question must not fast-path on an amber-zone action chunk. expect(