Triage v4 — orchestrator + MSK pathway library

superseded by v5-narrower.html · supersedes v2-algo.html

Last updated 2026-05-21 · live at triage-rn.dev.hiolly.com/demo/chat · MSK viewer /msk/ · coverage vs NHS /msk/coverage.html · v5 narrower →

TL;DR

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.

/v4/chat/completions single endpoint
39 pathways 1 hand-authored + 38 compiled MSK
gemini-3.5-flash everywhere
draft 38 packs need clinician sign-off

End-to-end flow

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

The three layers

Layer 1 — pathway narrowing (v3 narrower)

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:

actionWhat v4 does next
pathwayHand off to Layer 2 with the picked pw_code.
askRender the differential question + options. Wait for the patient's pick.
escapeTurn cap reached. Pin to a declared fallback pw_code.
out_of_scopeNo pathway in pool for this demographic + complaint. Render a terminal.

Narrower mechanics — how it decides per turn

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:

  1. Shortlist — query list_pathways for the demographic-filtered enabled pool. Trim to candidates whose label/pick_hint keyword-overlaps the accumulated user text.
  2. LLM rank — gemini-3.5-flash ranks the shortlist with confidence scores. If the top candidate is ≥ 0.85 confidence with a clear margin over runner-up, commit (action="pathway").
  3. Differential — otherwise, ask an LLM-generated differential question with 2-4 options. Each option binds to a candidate pw_code (or is a "I'm not sure" / "(asks again)" continuation).
  4. option_index short-circuit — when the next turn carries an option_index matching a previously-offered option, v3 commits directly to that option's bound pw_code without re-running the LLM rank.
  5. Hard cap — after V3_MAX_TURNS (3) ask turns, emit action="escape" with a declared fallback pw_code.

Worked example — narrowing from vague to specific

Three real traces captured against the live /v3/narrow endpoint (Male / 35 persona). Each row is one HTTP round-trip.

Case A — confident commit on turn 1

TurnPatient saysv3 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.

Case B — two-step narrowing (1 ask, then commit)

TurnPatient saysv3 returns
1 "my joints feel stiff and painful" action: ask
Q: "Which joints are currently causing you the most pain and stiffness?"
  • My hand or wrist joints
  • My foot or ankle joints
  • I'm not sure
2 "My hand or wrist joints" action: pathway pw_code = olly_msk_024 confidence 0.90
Rationale: "Patient selected differential option mapping to olly_msk_024 (Hand or wrist OA)."

"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.

Case C — three-step narrowing

TurnPatient saysv3 returns
1 "I have pain" action: ask
Q: "Where is your pain located?"
  • In your lower back
  • In your stomach or abdomen
  • In your chest
  • In the side of your body
  • I'm not sure
2 "in my upper body" soft commit pw_code = PA21 confidence 0.85
Tentative chest/upper-body pathway pick. The patient hasn't narrowed enough; on a real run the bot would walk PA21's RF gates from here.
3 "my shoulder hurts when I lift my arm" action: pathway pw_code = olly_msk_012 confidence 0.85
Rationale: "Shoulder pain specifically when lifting the arm — classic clinical presentation of subacromial shoulder impingement."

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.

Why this works: v3 is not a single one-shot classifier. It's a stateful conversation over Valkey, re-ranking each turn with the full free-text history. Mistakes on intermediate turns are recoverable as long as the user keeps adding signal.

Layer 2 — pathway walk (v1 agent on the picked pathway)

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.

Layer 3 — disposition (Olly-styled terminal)

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 | …

The two Ver-Stacks (parallel)

Ver-Stack 1 · RF sidecar

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.

Writes triage.session_flags + OTel span + Valkey state. Observe-only; never changes routing.

Ver-Stack 2 · tone formatter

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).

The judge (tone_judge.py) is test-only — never runs on live patient replies.

MSK pathway library

39 enabled pathways in dev-2 nhs111.pathways:

olly_back_mvp

1

Hand-authored 6-gate back-pain MVP. Reference for clinical voice and structural discipline.

olly_msk_*

38

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.

Booking prompt — terminal UX

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_typePrompt
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?"
emergencyBypassed. 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.

What changed since v2

areav2v4
BFF call shapev2-then-v1 fallback (two POSTs on non-back-pain)Single POST to /v4/chat/completions
Pathway pickv2 hardcoded olly_back_mvp; everything else 501→v1v3 narrower picks from full pool (39 pathways)
Out-of-scope handlingv2 wrote "Out of scope (v2)" terminal on every non-back-pain; v1 fallback wrote into a corpse sessionv3 picks correct pathway; session stays active through the walk
Session continuity13 sessions for one conversation (mint-fresh-on-completed bug)One session per conversation; layer_action=continue on follow-up turns
Tone enforcementImplicit via v1's system promptVer-Stack 2: tone_filter rewrites every assistant_text via Olly voice spec
Modelgemini-3-flash-preview (default) + 2.5-flash (agent walker)gemini-3.5-flash everywhere
MSK coverage1 pathway (back pain)39 pathways (back + 38 MSK packs across spine / shoulder / elbow / hand / hip / knee / foot / rheum / trauma)
Patient walk length15+ turns for back pain~5 turns (RF gates only; HQ chain dropped)
Terminal UXVerbose clinical advice text + Book Appointment pillPer-care-type Olly-tone prompt + Yes/No

Agent functions & tools

v4 reuses v1's pydantic-ai agent and tool registry. The key tools (defined in src/triage/tools.py + invoked by the agent loop):

toolpurposewhere v4 calls it
list_pathwaysReturns the enabled pool of pathways with pick_hints, demographic-filtered.Layer 1 — v3 narrower's shortlist step.
propose_pathway_candidatesAgent's classic pathway picker (free-text → ranked candidates).Blocked by gateway_rules once v3 has pinned. v4 doesn't call this.
start_sessionPins a pw_code onto the session row in postgres.Layer 2 — pin_and_handoff calls this once on commit.
advanceMove from current_state via the picked option; return next state.Layer 2 — every continuing-turn agent call invokes this.
red_flag_checkSubstring scan for Layer-0 emergency markers in the user's text.v1's pre-agent short-circuit (unchanged).
extract_factsLLM-based fact extraction from the patient's free-text reply.Internal to agent; not v4-specific.

Tests & observability

LayerTestWhere
Backend unittest_pin_and_handoff, test_v3_narrow_step, test_tone_filter, test_v4_orchestrator, test_gateway_rules_pinnedservices/triage/tests/
Backend e2eV4-001 / 002 / 003 scenarios — back pain, headache, ambiguous chestservices/triage/tests/e2e/scenarios/V4-*.v4.json
10-case MSK walkerLLM-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 / BFFv4-routing.spec.ts (5 API tests) + v4-ui-smoke.spec.ts (1 UI smoke)triage-rn/e2e/
TraceLangfuse traces every v4 request; triage.session_flags rows record every RF observer position update.langfuse.dev.hiolly.com

Try it

Live URL: https://triage-rn.dev.hiolly.com/demo/chat
Try chief complaints like "I have lower back pain", "my neck is stiff", "my knee aches on stairs", "I rolled my ankle", "my big toe is red and swollen". Walk each with "none of these apply" / "no" answers to reach the booking prompt.
For the back-pain happy path: chief complaint → 5 RF gates ("None of these apply" each) → physio booking prompt → "Yes, please" → /demo/chat/booking. ~6 turns total.

Direct API smoke (no UI)

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"
}

Branches & commits

RepoBranchNotable 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