Triage v2 — algorithm flow

POST /v2/chat/completions · model olly-mvp · pathway pinned to olly_back_mvp · source

v2 is v1's pydantic-ai agent locked to a single Olly-owned pathway, with two narrow additions before the agent runs: a universal red-flag short circuit and a back-pain purity gate. Continuation turns run through a stripped walker agent (two tools only: advance + red_flag_check) on gemini-3.5-flash.

End-to-end flow

Decision / dispatch
Gate (LLM)
Tree walk
Refusal / terminal
Side-effect
flowchart TD
    REQ(["POST /v2/chat/completions<br/>body: messages[], chat_id"])
    AUTH[/"Resolve user<br/>PERSONAS_V2['olly-mvp'] = grant_35"/]
    LASTMSG["last_user_msg = last message in req.messages"]

    CHATID{"chat_id
supplied?"} LOOKUP[("SELECT status<br/>FROM triage.sessions<br/>WHERE id = chat_id")] BUG2{"status =
'completed'?"} MINT["mint fresh<br/>session_id = uuid4()"] REUSE["session_id = chat_id"] ENSURE[("_ensure_session_row
INSERT IF NOT EXISTS")] RF_SIDE[("rf_sidecar.observe_turn
fire-and-forget, async")] PINNED{"_has_pathway_pinned?<br/>pathway_id ≠ NULL<br/>AND status = 'active'"} %% --- continuation walker --- WALK["_v2_walk_continuation"] LOAD_HIST[("Valkey GET
triage:v2_walk_history:
<user>:<session>")] AGENT_RUN[/"walker agent.run(last_user_msg)<br/>model: gemini-3.5-flash<br/>tools: advance, red_flag_check"/] ADVANCE[("tools.advance<br/>_match_option (strict)<br/>UPDATE current_state_id")] RFC_MID[/"red_flag_check<br/>volunteered new red flag?"/] NEXT{"is_terminal?"} RENDER_Q["render: question + numbered options"] RENDER_T["render: **Title** + advice + safety net"] SAVE_HIST[("Valkey SET history<br/>TTL 24h")] REC_ASST[("_record_assistant_turn")] COMPLETE[("_complete_session
status='completed'
disposition jsonb")] %% --- first turn --- L0[/"Layer 0 — red_flag_check<br/>substring scan on last_user_msg"/] L0_URG{"urgent?"} T_999["999 terminal<br/>_set_session_state + _complete_session"] L2[/"Layer 2 — purity classifier<br/>gemini-flash, A/B/C"/] L2_VER{"verdict?"} REFUSE["HTTP 501<br/>out_of_scope_v2 / _mixed"] PIN[("tools.start_session
pw_code='olly_back_mvp'
set pathway_id + current_state_id")] WARM["_first_turn_warm_emit<br/>synthetic history → agent.run"] SAVE_FT[("Valkey SET history<br/>(per-session key)")] RESP(["200 OpenAI envelope<br/>+ X-Triage-Session-Id<br/>+ X-Triage-V2-State-Id"]) REQ --> AUTH --> LASTMSG --> CHATID CHATID -- yes --> LOOKUP --> BUG2 BUG2 -- yes --> MINT BUG2 -- no --> REUSE CHATID -- no --> MINT MINT --> ENSURE REUSE --> ENSURE ENSURE --> RF_SIDE --> PINNED PINNED -- yes --> WALK WALK --> LOAD_HIST --> AGENT_RUN AGENT_RUN -. tool .-> ADVANCE AGENT_RUN -. tool .-> RFC_MID ADVANCE --> NEXT NEXT -- no --> RENDER_Q --> SAVE_HIST --> REC_ASST --> RESP NEXT -- yes --> RENDER_T --> SAVE_HIST --> COMPLETE --> REC_ASST --> RESP PINNED -- no --> L0 L0 --> L0_URG L0_URG -- yes --> T_999 --> REC_ASST L0_URG -- no --> L2 L2 --> L2_VER L2_VER -- B or C --> REFUSE L2_VER -- A --> PIN --> WARM --> SAVE_FT --> REC_ASST classDef dec fill:#dbeafe,stroke:#93c5fd,color:#1a1a1a; classDef gate fill:#fef3c7,stroke:#fcd34d,color:#1a1a1a; classDef walk fill:#dcfce7,stroke:#86efac,color:#1a1a1a; classDef refuse fill:#fee2e2,stroke:#fca5a5,color:#1a1a1a; classDef side fill:#e5e7eb,stroke:#cbd5e1,color:#1a1a1a; class CHATID,BUG2,PINNED,L0_URG,L2_VER,NEXT dec; class L0,L2,AGENT_RUN,RFC_MID gate; class WALK,ADVANCE,WARM,PIN,RENDER_Q,RENDER_T walk; class REFUSE,T_999 refuse; class LOOKUP,ENSURE,RF_SIDE,LOAD_HIST,SAVE_HIST,SAVE_FT,REC_ASST,COMPLETE side;

Back-pain tree it walks

Pre-pinned at first turn via tools.start_session(pw_code='olly_back_mvp'). Six NICE-cited gates, each pulled from postgres pathway_options. The "Yes" branch on every gate is a red-flag exit; the "No" branch advances. STarT Back stratification (Q7) is deferred — Q6 "No" routes directly to the physio terminal.

flowchart LR
    Q1{"Q1 — Cauda equina?<br/>saddle / bladder / bowel<br/>NICE NG59"}
    Q2{"Q2 — Progressive weakness?<br/>NICE NG59"}
    Q3{"Q3 — Cancer red flags?<br/>weight loss / night pain<br/>NICE NG12"}
    Q4{"Q4 — Inflammatory?<br/>AM stiffness ≥30 min<br/>NICE NG65"}
    Q5{"Q5 — Significant trauma?<br/>NICE NG41"}
    Q6{"Q6 — Systemic illness?<br/>fever / IVDU<br/>NICE NG59"}

    T_999["T_999_cauda_equina<br/>Call 999 / A&E"]
    T_GP_W["T_samed_gp_weakness<br/>Same-day GP"]
    T_2WW["T_gp_2ww_cancer<br/>GP — 2-week wait"]
    T_RHEUM["T_gp_rheum_inflam<br/>GP — rheumatology"]
    T_AE["T_ae_trauma_imaging<br/>A&E — imaging"]
    T_INF["T_samed_gp_infection<br/>Same-day GP"]
    T_PHYSIO["T_olly_physio_routine<br/>Book Olly physiotherapist"]

    Q1 -- "1-3 (Yes)" --> T_999
    Q1 -- "4 None" --> Q2
    Q2 -- "1-2 (Yes)" --> T_GP_W
    Q2 -- "3 No" --> Q3
    Q3 -- "1-4 (Yes)" --> T_2WW
    Q3 -- "5 None" --> Q4
    Q4 -- "1 AM stiff" --> T_RHEUM
    Q4 -- "2-3 No" --> Q5
    Q5 -- "1 Significant" --> T_AE
    Q5 -- "2-3 No" --> Q6
    Q6 -- "1-2 (Yes)" --> T_INF
    Q6 -- "3 No" --> T_PHYSIO

    classDef gate fill:#fef3c7,stroke:#fcd34d,color:#1a1a1a;
    classDef urgent fill:#fee2e2,stroke:#fca5a5,color:#1a1a1a;
    classDef gp fill:#fef3c7,stroke:#fcd34d,color:#1a1a1a;
    classDef routine fill:#dcfce7,stroke:#86efac,color:#1a1a1a;

    class Q1,Q2,Q3,Q4,Q5,Q6 gate;
    class T_999,T_AE urgent;
    class T_GP_W,T_2WW,T_RHEUM,T_INF gp;
    class T_PHYSIO routine;

What changed in the latest commit

BeforeAfter
Continuation turn → _v1_chat_completions (full v1 agent, 10+ tools, gateway rules) _v2_walk_continuation → stripped agent with only advance + red_flag_check
Model: gemini-2.5-flash via Settings().gemini_model Model: gemini-3.5-flash (override via V2_WALK_MODEL env) — measured ~3.4s/turn vs ~6s/turn on gemini-2.5-pro; Playwright walk 13.8s end-to-end vs 21.0s on pro. No option hallucinations observed.
Valkey key: triage:chat_history:<user>:sha1(first_user_msg)[:12] Valkey key: triage:v2_walk_history:<user>:<session_id> — no conv-fp drift
Agent could call list_pathways, propose_pathway_candidates, extract_facts, etc. — drifted to free-form clarification mid-walk Two tools only. LLM still paraphrases free text → option label, then strict _match_option verifies

Known residual

Even with the stripped agent the LLM can emit final_result with hallucinated options instead of calling advance first. We tried gemini-2.5-pro (slower, reliable in test) and then dropped to gemini-3.5-flash (faster, also reliable in test — 8/8 curl turns + Playwright pass with no observed hallucinations). If drift recurs in the wild, the structural fix is the deterministic walker: call tools.advance ourselves and put an LLM-only-for-option-mapping step in front of it (port v3_narrow's _llm_map_freetext_to_index).

Prompts

Three prompts shape v2's behaviour. Each runs at a different point in the request lifecycle and on a different model.

Walker system prompt

gemini-3.5-flash · v2_chat_completions.py:_V2_WALK_PROMPT · runs on every continuation turn (Q2 → Q6 / terminal). The walker has TWO tools: advance, red_flag_check.
You are Olly's back-pain triage walker.

Context:
  - The patient's session is already pinned to the `olly_back_mvp`
    pathway. Q1's cauda-equina question has already been emitted on
    the previous turn.
  - You have exactly TWO tools: `advance` and `red_flag_check`.
  - The patient's latest reply is the chosen option (or a paraphrase
    of one) for the question they just saw. Your job is to walk the
    tree.

What you MUST do on every turn:
  1. If the patient's reply mentions a NEW acute red-flag symptom that
     isn't already captured by the current state's options (e.g.
     "actually my chest hurts now", "I lost vision in one eye"), call
     `red_flag_check(message=)`. If `urgent=True`, emit
     a terminal TriageOutput pointing them to 999 / A&E with a short
     safety net. Otherwise continue.
  2. Call `advance(chosen_option_text=)`. Pass the option's verbatim text as it appears in the
     state's options list — paraphrase the patient's reply into one of
     them. Do NOT call `advance` with the patient's literal text if it
     doesn't match an option label cleanly.
  3. Use the AdvanceResult to compose the next message:
     - If `is_terminal=True`: render the disposition (title + advice)
       as a warm Olly message, set `is_terminal=true`, attach the
       disposition object.
     - Else: render the next state's question + options as the user-
       facing message. Acknowledge the patient briefly in one short
       line ("Thank you.", "Got it.", "Okay."), then the question.

What you MUST NOT do:
  - Do not call any tool other than `advance` and `red_flag_check`.
  - Do not propose a different pathway — the pathway is pinned.
  - Do not ask the patient an unrelated clarifying question. If you
    can't paraphrase their reply to an option, call `advance` with
    your best guess and let the matcher decide.

Style:
  - British English. No em dashes. No diagnostic naming. Warm, short,
    sentence-case acknowledgements.

Layer 2 purity classifier prompt

gemini-3-flash-preview · v2_chat_completions.py:_classify_complaint_purity · runs on FIRST turn only, before the agent. Decides whether the complaint is centred on back pain. Bypass: a substring pre-check on obvious-back / obvious-mixed phrases returns "A" or skips the LLM when an obvious mixed marker is present.
The user's message describes a health problem. Decide which
category fits BEST:
A. "back_pain_only" — complaint centred on back pain
(lower / upper / mid back), possibly with related leg symptoms.
B. "back_pain_mixed" — back pain alongside another major
symptom (chest pain, abdominal pain, neuro beyond legs, fever
as the primary concern, etc.)
C. "not_back_pain" — not primarily about back pain.

Reply with EXACTLY one of: A, B, C

Message: "<text>"

A → proceed to start_session pin. B → HTTP 501 out_of_scope_v2_mixed. C → HTTP 501 out_of_scope_v2. The BFF then falls back to v1's chat completions which handles non-back-pain complaints freely.

First-turn warm-emit nudge

gemini-2.5-flash · v2_chat_completions.py:_first_turn_warm_emit · the system prompt is v1's full olly_system_prompt.md (cached, Langfuse-managed with file fallback). The agent receives a seeded pydantic-ai history where start_session already returned the first state (Q1). This nudge prompts the agent to render the seeded state warmly.
Now produce the first warm reply: acknowledge what the patient
said in one short line, then ask the question from the current
state with all its options listed. British English, no em
dashes, no diagnostic naming.

The full v1 system prompt lives at services/triage/src/triage/prompts/olly_system_prompt.md and gets re-fetched from Langfuse (key olly_system_prompt) at agent build time with the .md as the offline fallback. It's the same document v1 uses for any non-MSK complaint — see docs/CLINICAL_TRIAGE_BLUEPRINT.md for the layered model it encodes.

Agent functions

The triage service registers 12 pydantic-ai tools across its agents. v2's stripped walker uses only 2 of them (advance + red_flag_check). v1's full agent has access to all 12 — the broader surface is why v1 can drift and why we trimmed for v2. Every tool wraps a function in services/triage/src/triage/tools.py, so behaviour is consistent across endpoints.

v2 walker (continuation)
v2 first-turn agent (warm emit)
v1 only
Tool Signature Used by What it does · when the agent calls it
red_flag_check (message: str) → RedFlagResult walker first-turn v1 Layer 0 universal red-flag detector. Data-driven two-stage matcher (Stage 1 substring scan over lay_synonyms; Stage 2 semantic LLM is intentionally skipped here for sub-millisecond pre-pathway speed). Returns urgent=True with a disposition category (cauda equina, AAA, sepsis, stroke, MI, etc.) when matched.
Walker: when patient volunteers a new acute red-flag mid-walk ("actually my chest hurts now") → emit 999 terminal directly. First-turn / v1: gate every chief complaint before any pathway logic.
advance (chosen_option_text: str) → AdvanceResult walker v1 Walks the postgres pathway_options tree by one step. Strict 4-step _match_option verifies the supplied text (exact / numeric prefix / strict prefix / unambiguous substring), updates current_state_id := next_state_id, persists a user-turn into triage.turns, auto-skips Male+pregnancy questions. Returns the next state with its question + options + (if terminal) disposition.
Walker / v1: every continuation turn. Agent paraphrases the patient's free text into one of the option labels and passes it as chosen_option_text.
start_session (pw_code: str, chief_complaint: str) → StartedSession first-turn v1 Open a session pinned to a specific pathway. Writes pathway_id + current_state_id to triage.sessions, records turn 0 as the chief complaint, returns the first symptom-specific question.
First-turn: v2 calls this directly (not via the agent) to pre-pin olly_back_mvp before the warm-emit render. v1: agent calls after Layer 2 narrowing settles on a pathway.
list_pathways () → list[PathwayMatch] v1 Return ALL ~110 NHS pathways for the patient's (sex, age) demographic, with categorisation metadata (state_count, terminal_count, region tags). The agent reasons over the full list and asks clarifying questions when multiple are plausible. Gateway rule blocks this tool once start_session has been called.
propose_pathway_candidates (candidates: list[{pw_code}], confidence: float, clinical_summary: str) → CandidatesResult v1 Layer 2 narrowing engine. Server-side gates the agent's 1-5 candidate shortlist: returns proceed (call start_session), clarify (ask one discriminating question, optionally citing missing-evidence dimensions), or narrow_more (re-rank with escalation hints). Enforces evidence-sufficiency per the pathway's checklist.
get_state (pw_code: str, state_id: str) → State | None v1 Read one state from pathway_states for the current demographic. Returns None if the state isn't visible to that (sex, age). Mostly used by the agent for forward-looking peek (e.g. "is the next state a terminal?") and by debug tooling.
extract_facts (message: str, current_question: str = "") → list[Fact] v1 Pull clinical facts (duration, severity, location, onset, …) from a free-text patient message. Used by v1 to build the running clinical_summary for propose_pathway_candidates. The walker's drift failures often manifest as calling this with current_question='' instead of calling advance.
pharmacy_first_check (chief_complaint: str) → PharmacyFirstDecision | None v1 Layer 0.5 — NHS Pharmacy First gateway. Checks whether the chief complaint matches one of the 7 PF conditions (UTI, sore throat, sinusitis, otitis media, shingles, impetigo, infected insect bite) AND the patient is eligible. If both: disposition is "Speak to a pharmacist via Pharmacy First" — no further pathway walk needed. Gateway rule sequence: run AFTER red_flag_check clears, BEFORE list_pathways.
mental_health_check (chief_complaint: str) → MHDecision v1 Layer 1.5 — Mental Health gateway. Classifies anxiety / depression / PTSD / suicidal-ideation presentations. Returns tool_id = phq9 / gad7 / pc_ptsd_5 when an IAPT questionnaire is the right surface, or crisis_level='high' when an immediate Samaritans signpost is required. Gateway sequence: AFTER red_flag + pharmacy_first clear, BEFORE list_pathways.
mental_health_score (tool_id: str, answers: list[int]) → ScoreResult v1 Pure deterministic scoring for IAPT questionnaires. Take the ordinal answers the agent collected by walking the items from mental_health_check and return the band + routing (self-referral / IAPT / GP urgent), plus include_samaritans flag. Triggered after walking the full item set.
msk_check (chief_complaint: str) → MSKDecision v1 Layer 1.7 — Musculoskeletal / FCP gateway. Routes routine adult MSK to First Contact Physiotherapist directly instead of walking PW777 / PW782 / PW1140 etc. (which all terminate at nurse / GP / 999 in the NHS graph — no FCP leaf exists). Branches: route='fcp' → physio disposition; route='gp' → inflammatory-MSK GP-urgent; in_scope=false → fall through to list_pathways.
find_gp (postcode: str) → list[GP] v1 Postcode → nearby GP practices via the nhs111-scraper service. Called once a disposition recommends GP follow-up and the agent wants to surface specific contact details. Not on the back-pain demo path — the olly_back_mvp terminals route to "Book an Olly GP / physio", not to a specific surgery.

The strict matcher inside advance

tools._match_option is the deterministic gate. Strategy, strict → permissive:

  1. Exact lower-case match — "None of these apply" == option.text
  2. Numeric prefix — "4" picks option 4 (1-indexed)
  3. Strict prefix — "No" matches "No - I feel well…"
  4. Unambiguous substring — only when exactly one winner; "No" against a state with multiple "No-…" options returns None

If none of the four strategies finds a match, advance raises ValueError. Today the walker propagates that to a 500 — we rely on the agent paraphrasing well enough that this never fires in practice. The structural fallback (when we get to it) is to port v3_narrow._llm_map_freetext_to_index in as a second chance: same flash model, structured-JSON output of {"index": int}, called only when strict misses.

Why the walker trimmed 10 tools

Each tool the agent can call is a place the LLM can drift. v1's agent reaches for list_pathways / propose_pathway_candidates / extract_facts on continuation turns when the message history shape confuses it — none of which advance the pinned olly_back_mvp tree. Gateway rules block some chains after start_session, but the agent can still emit a final_result with a clarifying text question instead of calling advance. The walker exists because the only correct continuation action for a pinned olly_back_mvp session is advance; removing the choice removes the drift.

Tool-level test coverage

Each tool wraps a function in services/triage/src/triage/tools.py (or a sibling clinical module). Tests sit in three layers — unit tests over the underlying function, clinical-rule tests over the data tables a tool reads, and e2e scenarios that exercise the tool end-to-end through a real agent.

Tool Unit tests Clinical-rule tests E2E scenarios
red_flag_check test_red_flags.py · 4 tests — basic urgent vs clear, AAA/cardiac/stroke vignettes clinical/test_layer0_rules.py · 51 tests — every rule in the acute red-flag DB (cauda equina, AAA, MI, stroke, sepsis, anaphylaxis, ectopic, paeds shock, etc.) gets a positive + negative vignette 1 MVP-BP-LAYER0-001 + the 11 RF-* scenarios from the v1 corpus
advance test_advance.py · 2 tests — happy walk + Male+pregnancy auto-skip Every multi-turn scenario (14 MVP-BP, 49 v1 PoC, 200 V3) — advance is the workhorse
start_session test_start_session.py · 2 tests — open + shock-screen skip Every scenario opens a session
list_pathways test_list_pathways.py · 4 tests — full catalog returns, demographic filtering, state/terminal counts Implicit in every v1 first-turn flow
propose_pathway_candidates clinical/test_layer1_rules.py · 30 tests — narrowing rules, evidence-sufficiency gates, escalation triggers The agent's clarify/proceed/narrow_more behaviour exercised across all v1 scenarios
get_state test_get_state.py · 2 tests — visible vs invisible (demographic gate) Implicit (every walk reads states)
extract_facts test_extract_facts.py · 2 tests — empty input + fact extraction with question context Exercised in v1 narrowing flows
pharmacy_first_check clinical/test_pharmacy_first.py · 28 tests — the 7 PF conditions × eligibility/exclusion criteria (UTI in pregnancy, sinusitis >10 days, otitis media adult, shingles >72h, immunocompromised, etc.) 18 scenarios PF-001 … PF-018
mental_health_check clinical/test_scoring_tools.py · (part of the 29 tests) — classifier surface area (anxiety / depression / PTSD / crisis routing) 8 MH-* scenarios
mental_health_score clinical/test_scoring_tools.py · 29 tests — PHQ-9, GAD-7, PC-PTSD-5 scoring bands + Samaritans override MH-* scenarios that complete a questionnaire walk
msk_check Part of clinical/test_layer1_rules.py — FCP routing, inflammatory MSK gate, fall-through 6 MSK-* scenarios + the MVP-BP family relies on this Layer 1.7 not firing
find_gp test_find_gp.py · 2 tests — happy postcode + scraper-down fallback Not on demo path; rarely exercised in e2e
_match_option (private) Transitively covered by every test_advance case and every multi-turn e2e scenario

Totals at a glance

LayerFilesTests
Unit tests over tool functions test_red_flags.py, test_advance.py, test_start_session.py, test_list_pathways.py, test_get_state.py, test_extract_facts.py, test_find_gp.py ~18
Clinical-rule tests (over data tables) clinical/test_layer0_rules.py (51) + clinical/test_layer1_rules.py (30) + clinical/test_pharmacy_first.py (28) + clinical/test_scoring_tools.py (29) 138
E2E scenarios (real agent + LLM) 14 MVP-BP, 18 PF, 8 MH, 6 MSK, 49 v1 PoC (RF/RU/walk), 200 V3 narrower ~295
Infra tests (DB / OTel / auth / sessions / migrations / models) 9 files (test_db.py, test_otel.py, test_auth.py, test_sessions.py, etc.) ~25

Three test layers, three different jobs

Unit tests verify the function's contract — given these args, return this shape. Fast (no postgres / no Valkey, mocked pools where needed), no LLM. Lives in services/triage/tests/test_*.py.

Clinical-rule tests verify the data tables and the pure-Python matchers over them. The 51 Layer-0 tests are the most valuable safety net in the codebase — every red-flag rule has a "should match" and "should NOT match" vignette. The PF / MH / Layer 1 suites verify decision-tree branches (eligibility thresholds, age cutoffs, scoring bands). No LLM, no DB, sub-second to run the lot. Lives in services/triage/tests/clinical/.

E2E scenarios exercise tools through a real agent against the running stack. Persona + answer-rules drive the multi-turn conversation; scenario_evaluators assert on the resulting postgres row, Langfuse trace, disposition shape, and tone. Self- describing (every scenario carries its own evaluators inline). Run via tests/e2e/scripts/run_v*_wave.py. This is the only layer where LLM non-determinism shows up — failures here usually point at agent drift, not tool bugs.

What's NOT well covered

Test cases

v2 ships with 14 e2e scenarios under services/triage/tests/e2e/scenarios/MVP-BP-*.v2.json. Every scenario is self-describing: persona + answer rules driven by question-text matching, plus a list of evaluators that run post-session against postgres / Langfuse / disposition shape.

Family Count What it asserts
HAPPY-* 3 Clean Q1-Q6 walks ending at the physio terminal. STarT Back band variants (low / medium / high) — currently all land on T_olly_physio_routine since Q7 ingest is deferred.
RF-001 … RF-006 6 Each of the six red-flag gates fires its urgent terminal. Cauda equina → 999. Progressive weakness → same-day GP. Cancer red flags → 2-week-wait GP. Inflammatory → rheumatology GP. Trauma → A&E imaging. Systemic → same-day GP infection screen.
LAYER0-001 1 AAA-style "tearing chest pain radiating to back" — Layer 0 substring matcher fires before the agent, returns 999 + safety net. Verifies the universal red-flag short-circuit beats the back-pain pathway.
LAYER2-MIXED-001 / LAYER2-REFUSE-001 2 Purity classifier refuses HTTP 501 on "back pain AND abdo pain" (mixed) and on "I have a sore throat" (not back pain). BFF then falls back to v1.
LAYER5-ESCAPE-001 1 Mid-walk request to speak to a human → walker terminates gracefully with handover copy, doesn't keep asking gates.
BOUNDARY-001 1 STarT Back boundary scoring (psychosocial 3, total 4 → medium band). Currently behaves as deferred-Q7 happy path.

Scenario shape

Excerpted from MVP-BP-RF-002_progressive_weakness_samed_gp.v2.json:

{
  "name": "MVP-BP-RF-002_progressive_weakness_samed_gp.v2",
  "description": "Adult male with 2 weeks of back pain and progressive leg
                  weakness. Denies saddle/bladder/bowel (Q1) but affirms
                  worsening leg weakness (Q2). Expected: T_samed_gp_weakness.",
  "fixture_user": "grant_35",
  "model": "olly-mvp",
  "persona": {
    "intro": "I've had back pain for two weeks and now I'm noticing my legs
              feel weak when I try to walk.",
    "answer_rules": [
      { "if_question_contains": ["urgent attention", "saddle area"],
        "respond": "None of these apply" },
      { "if_question_contains": ["weakness in your legs", "getting worse"],
        "respond": "Yes, my legs feel weak and it is getting worse" },
      ...
    ],
    "max_turns": 8,
    "expected_pathway": "olly_back_mvp"
  },
  "scenario_evaluators": [
    { "type": "db_query",  "sql": "SELECT COUNT(*) FROM triage.sessions
                                    WHERE id = '${session_id}'
                                    AND disposition IS NOT NULL ...",
      "expected_min": 1 },
    { "type": "disposition_band_strict",
      "expected": "urgent", "expected_care_type": "gp" },
    { "type": "contains_any",
      "field": "assistant_message",
      "values": ["gp", "general practitioner", "doctor"] },
    { "type": "safety_net_present", "min_chars": 40 },
    { "type": "no_diagnostic_naming" }
  ]
}

Cross-cutting evaluators (auto-injected)

Every v2 scenario receives three defaults at runtime via runner_v2.py step 4b, even if the scenario JSON doesn't declare them:

TypeDefaultScope
tone_judgemin_score: 0.6Last assistant turn
elapsed_max_ms90000 (90 s wall)Full scenario
langfuse_cost_max0.50 USDScenario rollup

A scenario can tighten any default by declaring its own copy in scenario_evaluators[] — the auto-inject only fires when the type isn't already declared.

Running the wave

# From /root/olly/services/triage with the triage container running.
# Median-of-3 for variance filtering.
uv run python tests/e2e/scripts/run_v2_wave.py 'MVP-BP-*.v2.json' \
  --parallel 3 --runs 3

Frontend smoke (Playwright)

/root/triage-rn/e2e/grant-backpain.spec.ts drives the live URL end-to-end: greeting hero → "I have a back pain." → CARE tile → 3 clips → Q1 → "None of these apply" → Q2 progressive weakness. Authenticates as Grant by injecting a synthetic JWT with preferred_username=grant on every /api/chat POST, so v1's agent (when it runs) and v2's pinned session both resolve to grant_35.

cd /root/triage-rn
npx playwright test --config=e2e/playwright.config.ts

Try it

curl -sX POST 'https://triage-rn.dev.hiolly.com/api/chat?json=true' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <keycloak JWT for grant>' \
  -d '{"messages":[{"role":"user","content":"I have a back pain."}],
       "chat_id":"<new-uuid>"}'
Last regenerated by hand. The flow above reflects code on branch fix/v2-refusal-audit-and-completed-session as of the latest walker commit. The triage container hot-reloads from source.