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.
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;
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;
| Before | After |
|---|---|
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 |
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).
Three prompts shape v2's behaviour. Each runs at a different point in the request lifecycle and on a different model.
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.
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.
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.
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.
| 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.
|
advance
tools._match_option is the deterministic gate. Strategy,
strict → permissive:
"None of these apply" == option.text"4" picks option 4 (1-indexed)"No" matches "No - I feel well…"
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.
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.
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 |
| Layer | Files | Tests |
|---|---|---|
| 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 |
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.
_match_option has no dedicated unit test
— its four strategies are only exercised through advance's
2 tests. A focused suite would catch regressions on the substring/prefix
edge cases (ambiguity rejection) cheaply.
_v2_walk_continuation with a fixed Valkey history blob
+ a fixture user would lock the no-drift behaviour without
paying for an LLM call. On the to-do list.
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. |
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" }
]
}
Every v2 scenario receives three defaults at runtime via
runner_v2.py step 4b, even if the scenario JSON doesn't
declare them:
| Type | Default | Scope |
|---|---|---|
tone_judge | min_score: 0.6 | Last assistant turn |
elapsed_max_ms | 90000 (90 s wall) | Full scenario |
langfuse_cost_max | 0.50 USD | Scenario 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.
# 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
/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
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>"}'