v4 is a single endpoint that orchestrates three sequential layers and two parallel "Ver-Stacks". The BFF makes one POST; v4 picks a pathway via the narrower (Layer 1), walks it through the agent (Layer 2), and renders a disposition (Layer 3). The RF observer and tone formatter run in parallel on every turn.
flowchart TD user([Patient]) bff["BFF /api/chat
(triage-rn)"] v4["/v4/chat/completions"] subgraph L1["Layer 1 — Pathway narrowing"] direction TB v3["v3 narrower
narrow_step(state, user, messages)"] v3a{"action"} end subgraph L2["Layer 2 — Pathway walk"] direction TB pin["pin_and_handoff
start_session(pw_code) + seed history"] v1["v1 agent.run
advance(option) per turn"] end subgraph L3["Layer 3 — Disposition"] direction TB map["disposition_map.map_to_olly
Olly-styled terminal"] end subgraph VS["Ver-Stacks (parallel)"] direction LR rf["Ver-Stack 1: RF sidecar
async observe_turn"] tone["Ver-Stack 2: tone_filter
Olly voice spec applied"] end user --> bff --> v4 v4 --> v3 v3 --> v3a v3a -.->|ask| user v3a -->|pathway| pin v3a -.->|escape / out_of_scope| user pin --> v1 v1 -->|"is_terminal=false"| v1 v1 -->|"is_terminal=true"| map map --> tone v1 --> tone v4 -.-> rf tone --> bff --> user classDef layer fill:#eef2ff,stroke:#4338ca,color:#312e81 classDef vs fill:#fff8eb,stroke:#b45309,color:#78350f classDef ep fill:#dbeafe,stroke:#2563eb,color:#1e40af classDef user fill:#f5f5f4,stroke:#475569,color:#1a1a1a class L1,L2,L3 layer class VS vs class v4,bff ep class user user
narrow_step() looks at the patient's free-text complaint, shortlists candidate pathways from the enabled pool in dev-2 nhs111.pathways, and either commits to one or asks a 2-3 turn differential. Returns one of:
| action | What v4 does next |
|---|---|
| pathway | Hand off to Layer 2 with the picked pw_code. |
| ask | Render the differential question + options. Wait for the patient's pick. |
| escape | Turn cap reached. Pin to a declared fallback pw_code. |
| out_of_scope | No pathway in pool for this demographic + complaint. Render a terminal. |
State is kept in Valkey keyed on chat_id, so each turn the narrower sees the full accumulated free-text plus a turn counter. The flow per turn:
list_pathways for the demographic-filtered enabled pool. Trim to candidates whose label/pick_hint keyword-overlaps the accumulated user text.0.85 confidence with a clear margin over runner-up, commit (action="pathway").pw_code (or is a "I'm not sure" / "(asks again)" continuation).option_index matching a previously-offered option, v3 commits directly to that option's bound pw_code without re-running the LLM rank.V3_MAX_TURNS (3) ask turns, emit action="escape" with a declared fallback pw_code.Three real traces captured against the live /v3/narrow endpoint (Male / 35 persona). Each row is one HTTP round-trip.
| Turn | Patient says | v3 returns |
|---|---|---|
| 1 | "I have lower back pain" |
action: pathway
pw_code = olly_msk_001
confidence 0.90
|
Clear single-pathway match — the LLM ranker finds olly_msk_001 "Low back pain (non-specific)" above threshold on the first read. No narrowing needed. Layer 2 takes over immediately.
| Turn | Patient says | v3 returns |
|---|---|---|
| 1 | "my joints feel stiff and painful" |
action: ask Q: "Which joints are currently causing you the most pain and stiffness?"
|
| 2 | "My hand or wrist joints" |
action: pathway
pw_code = olly_msk_024
confidence 0.90 |
"Painful, stiff joints" is anatomically ambiguous — could be hand OA, foot OA, rheumatoid arthritis, polymyalgia, or gout. v3 asks one differential keyed on body region. Each option is pre-bound to a pw_code; the user's pick short-circuits straight to olly_msk_024.
| Turn | Patient says | v3 returns |
|---|---|---|
| 1 | "I have pain" |
action: ask Q: "Where is your pain located?"
|
| 2 | "in my upper body" |
soft commit
pw_code = PA21
confidence 0.85 |
| 3 | "my shoulder hurts when I lift my arm" |
action: pathway
pw_code = olly_msk_012
confidence 0.85 |
The accumulated free-text drives the ranker on every turn. "I have pain" alone is too vague — v3 asks for location. "Upper body" narrows but is still imprecise, so v3 makes its best soft-commit guess. Once the patient adds "shoulder when I lift my arm" on turn 3, the ranker reweights with the new signal and commits to olly_msk_012 (Shoulder impingement). The full accumulated context — three user messages — is what wins.
Once Layer 1 commits, pin_and_handoff() writes the pw_code onto the session and seeds the pydantic-ai message history with a synthetic start_session tool call+return. Every subsequent user reply forwards to v1's chat_completions handler, which runs the agent's advance() tool to step the FSM. Gateway rules block the agent from re-picking the pathway (so v3's choice stands).
The agent walks the canonical state machine defined in nhs111.pathway_states / pathway_options. Each gate is a single question with a list of patient-language signs; user picks an option; advance() returns the next state. When the next state is a terminal, the agent's TriageOutput.is_terminal=True and Layer 3 fires.
For NHS pathways (PW*), disposition_map.map_to_olly() rewrites the NHS title and care_type into Olly's UX ("Book an Olly physiotherapist", "Speak to an Olly GP"). For Olly pathways (olly_*), the disposition is already Olly-styled at source — the map early-returns the input verbatim.
The terminal disposition is what the patient ultimately sees, with three structured signals attached to the response:
X-Triage-Is-Terminal: true X-Triage-Care-Type: physio | gp | emergency | nurse | mental_health | pharmacy | self_care | specialist X-Triage-Disposition-Title: Book an Olly physiotherapist | Call 999 | Go to A&E now | …
Async fire-and-forget on every patient turn. Maintains a per-session "position vector" over red-flag categories so cumulative patterns (back pain turn 1 + weight loss turn 4 + night pain turn 6) get caught even when no single message crosses the Layer-0 threshold.
Production formatter that takes the agent's assistant_text and rewrites it to comply with the Olly voice spec. Single gemini-3.5-flash call; fail-open (any error returns original text).
39 enabled pathways in dev-2 nhs111.pathways:
Hand-authored 6-gate back-pain MVP. Reference for clinical voice and structural discipline.
Auto-compiled from Appendix-C clinical evidence packs. All marked pack_status: draft until clinician sign-off.
Each MSK pack has: 5 red-flag gates (each with a patient_question + patient_signs[] routing to an emergency/urgent terminal) → default terminal (typically physio booking). The original auto-compile also produced a history-question chain (HQ1…HQ10), but every HQ Y/N routed to the next HQ regardless of answer — pure dead weight from a patient-experience POV. All 332 HQs are now patient_skip: true, so the runtime tree is RF gates + terminals only. Patient walks dropped from ~15 turns to ~5.
Full viewer at arch.dev.hiolly.com/msk/ with one decision-tree page per pathway. NHS-vs-Olly coverage matrix at /msk/coverage.html.
When v4 reaches a non-emergency terminal, the chat client (per the X-Triage-Care-Type header) replaces the verbose canonical disposition with a single Olly-tone prompt. The patient sees who they'd be booking with and what to expect:
| care_type | Prompt |
|---|---|
physio | "Based on what you've shared, an Olly physiotherapist is the best next step. They'll guide you through tailored exercises, hands-on care, and a plan to get you moving more comfortably. Want me to book you in?" |
gp | "An Olly GP is the right next step for this. They can examine you in detail, run any tests, prescribe what you need, and refer you on if it helps. Shall I get you an appointment?" |
mental_health | "An Olly mental health practitioner can support you with this. The first session is a friendly conversation to understand what's been going on and figure out what would help most. Shall I arrange that?" |
nurse | "An Olly nurse can help with this. They'll check things over, give clear advice, and arrange any follow-up you need. Want me to book that in?" |
pharmacy | "An Olly pharmacist can sort this for you. They're trained to advise on, and prescribe for, many common things without needing a GP appointment. Want me to point you to one?" |
self_care | "Good news, this is usually something you can manage at home with a bit of time. Want a short guide on what to do, plus a reminder to check back in if things don't settle?" |
emergency | Bypassed. The canonical 999 / A&E safety advice renders in full. |
Chips: "Yes, please" / "Not right now". Yes navigates to /demo/chat/booking + appends the AppointmentPill. No closes softly.
| area | v2 | v4 |
|---|---|---|
| BFF call shape | v2-then-v1 fallback (two POSTs on non-back-pain) | Single POST to /v4/chat/completions |
| Pathway pick | v2 hardcoded olly_back_mvp; everything else 501→v1 | v3 narrower picks from full pool (39 pathways) |
| Out-of-scope handling | v2 wrote "Out of scope (v2)" terminal on every non-back-pain; v1 fallback wrote into a corpse session | v3 picks correct pathway; session stays active through the walk |
| Session continuity | 13 sessions for one conversation (mint-fresh-on-completed bug) | One session per conversation; layer_action=continue on follow-up turns |
| Tone enforcement | Implicit via v1's system prompt | Ver-Stack 2: tone_filter rewrites every assistant_text via Olly voice spec |
| Model | gemini-3-flash-preview (default) + 2.5-flash (agent walker) | gemini-3.5-flash everywhere |
| MSK coverage | 1 pathway (back pain) | 39 pathways (back + 38 MSK packs across spine / shoulder / elbow / hand / hip / knee / foot / rheum / trauma) |
| Patient walk length | 15+ turns for back pain | ~5 turns (RF gates only; HQ chain dropped) |
| Terminal UX | Verbose clinical advice text + Book Appointment pill | Per-care-type Olly-tone prompt + Yes/No |
v4 reuses v1's pydantic-ai agent and tool registry. The key tools (defined in src/triage/tools.py + invoked by the agent loop):
| tool | purpose | where v4 calls it |
|---|---|---|
list_pathways | Returns the enabled pool of pathways with pick_hints, demographic-filtered. | Layer 1 — v3 narrower's shortlist step. |
propose_pathway_candidates | Agent's classic pathway picker (free-text → ranked candidates). | Blocked by gateway_rules once v3 has pinned. v4 doesn't call this. |
start_session | Pins a pw_code onto the session row in postgres. | Layer 2 — pin_and_handoff calls this once on commit. |
advance | Move from current_state via the picked option; return next state. | Layer 2 — every continuing-turn agent call invokes this. |
red_flag_check | Substring scan for Layer-0 emergency markers in the user's text. | v1's pre-agent short-circuit (unchanged). |
extract_facts | LLM-based fact extraction from the patient's free-text reply. | Internal to agent; not v4-specific. |
| Layer | Test | Where |
|---|---|---|
| Backend unit | test_pin_and_handoff, test_v3_narrow_step, test_tone_filter, test_v4_orchestrator, test_gateway_rules_pinned | services/triage/tests/ |
| Backend e2e | V4-001 / 002 / 003 scenarios — back pain, headache, ambiguous chest | services/triage/tests/e2e/scenarios/V4-*.v4.json |
| 10-case MSK walker | LLM-driven persona walks 10 MSK chief complaints to terminal. 10/10 reach clean terminal, avg ~5 turns. | services/triage/tests/e2e/scripts/walk_msk_llm.py |
| Frontend / BFF | v4-routing.spec.ts (5 API tests) + v4-ui-smoke.spec.ts (1 UI smoke) | triage-rn/e2e/ |
| Trace | Langfuse traces every v4 request; triage.session_flags rows record every RF observer position update. | langfuse.dev.hiolly.com |
BEARER="olly_triage_grant_35_0UkEemrBIVaZPnQ0-JKvLQ"
curl -X POST https://triage-rn.dev.hiolly.com/api/chat?json=true \
-H "Authorization: Bearer $BEARER" \
-H "Content-Type: application/json" \
-d '{"chat_id":"'$(uuidgen)'","messages":[{"role":"user","content":"I have lower back pain"}]}'
Returns:
{
"text": "Two weeks of …",
"session_id": "",
"is_terminal": false,
"care_type": null,
"disposition_title": null,
"pathway_id": "olly_msk_001"
}
| Repo | Branch | Notable commits today |
|---|---|---|
olly |
fix/v2-refusal-audit-and-completed-session |
0f3ed9b X-Triage-Is-Terminal/Care-Type headers ·
4066c13 MSK coverage page ·
435160c drop HQ chain ·
083364d MSK viewer + LLM walker ·
6248199 38 MSK packs + patient layer ·
bb7b95d v4 orchestrator
|
triage-rn |
ci/eas-quota-cuts |
b86fd2d per-care-type Olly-tone booking prompt ·
5afedd2 v4 BFF + booking prompt
|