The v5 narrower does both Layer-1 capture (extract 17 structured clinical fields from free text) and routing (commit to a pw_code) in a single LLM call per turn. It never rejects a patient and never hits a hard turn cap — information completeness drives commit, and frustration is the only forced exit. Tone formatting wraps every reply with conversation-aware Olly voice (no repeated openers, no em dashes, empathy preserved on frustrated turns).
Every field is optional. If the patient didn't mention it, the LLM is required to leave it null rather than guess — except for a small set of "explicit implications" (e.g. "I twisted my ankle" → onset: sudden because twist is by definition instantaneous).
| Field | Type | Tier | What it captures |
|---|---|---|---|
| presenting_complaint | text | always-filled | The chief complaint in the patient's words. Excluded from completeness math (would inflate the score). |
| anatomical_region | enum | CRITICAL | Body region (back_lower, knee, chest, ear, …). Required for routing — most pathways are region-scoped. |
| onset | enum | CRITICAL | sudden | gradual | unknown. Implicit-fill: twist/fall/knock → sudden; "crept on" → gradual. |
| duration | text | counted | How long it's been going on. Short free text ("three days", "since Tuesday"). |
| trigger | text | CRITICAL | What caused it / what they were doing. "after lifting boxes", "playing football". |
| pain_score | int 0–10 | CRITICAL | Numeric severity. Only filled if the patient gave a number — never guess. |
| pain_character | enum | counted | sharp | dull | burning | aching | throbbing | stabbing | unknown. |
| radiation | text | counted | Where the pain spreads. "down the left leg", "into the jaw". |
| function_impact | enum | CRITICAL | none | mild | moderate | severe | unable. Implicit-fill: "can't stand" → unable. |
| sleep_impact | bool | counted | true / false. Implicit: "slept like a baby" → false; "tossed and turned" → true. |
| work_impact | bool | nice-to-have | Implicit: "called in sick" → true; "still working" → false. |
| mood_signal | enum | counted | low | anxious | frustrated | neutral | unknown. Frustrated triggers special handling — see below. |
| worry_score | int 0–10 | nice-to-have | Only if patient stated a number. |
| prior_episodes | bool | counted | Implicit: "this happened last year" → true. |
| self_treatment | text | counted | "paracetamol", "ice pack", "rest". Free text. |
| what_they_think | text | nice-to-have | The patient's own theory ("I think I've slipped a disc"). Captured for audit, never echoed back per spec. |
| red_flag_mentions[] | list | CRITICAL | Tokens patient spontaneously mentioned: weight_loss, night_pain, saddle_anaesthesia, foot_drop, bladder_control, chest_pain, haemoptysis, trauma, etc. Tagged not routed — Layer 2 verifies. |
| safeguarding_flags[] | list | tag-only | suicidal_ideation, self_harm, domestic_abuse, etc. Surfaced in audit; routing override deferred pending clinical sign-off. |
Every user turn runs narrow_step(). The LLM ranks pathway candidates AND extracts the 17 fields in one call. Then five branches decide what to do:
escalate_gp. Bot says: "I can tell this isn't working for you, and I'm sorry about that. It might be quicker to speak to your GP directly — or call NHS 111." Session stays unpinned so a future cooperative turn can re-enter narrow_step.ask with tone_hint=empathic. Bot acknowledges the frustration, briefly explains why we ask, then ONE simple priority question. Counter increments; if next turn also frustrated, branch 2 again; if next turn neutral, counter resets to 0.pathway, commit_path: confidence_fast. Skips the 0.80 completeness floor. This is the fix for the "asked 5 turns about an obvious ankle sprain" case.
critical_fields_filled() requires:
1. anatomical_region present, AND
2. trigger OR onset present, AND
3. function_impact OR pain_score present
pathway, commit_path: completeness_gate. Standard path when patient gives a rich initial message.ask. If the patient's last reply added no new info (completeness delta ≤ 0) AND mood ≠ frustrated → tone_hint=vague_rephrase: gently rephrase the previous question instead of pivoting. Otherwise: fresh multi-field fact-finding.ask with structured chip options. The LLM writes one clinical-feature differential question (mechanism / anatomy / timing) and maps each option to a candidate's pw_code.| Archetype | Signals | Bot behaviour |
|---|---|---|
| Engaged + giving rich info | Long first message, multiple fields fillable | Branch 3a or 3b on turn 1–2 → straight to pathway commit. |
| Engaged + sparse | Short complaint, fills 1–2 fields per turn | Branch 4 (fact-finding) repeatedly. No turn cap — keeps going until completeness ≥ 0.80 or critical fields fill. |
| Polite but vague | "yeah it hurts", "i dunno" — no completeness delta | Branch 4 with vague_rephrase tone — rephrase the previous question gently. "That's completely okay. Could you tell me…" Keep going indefinitely. |
| Frustrated | "why are you asking so many questions?", "this is too much" | Branch 2 (empathic ask) for turns 1–2. Counter increments. At turn 3 consecutive → Branch 1 (escalate_gp). |
Every narrower response is post-processed by enforce_tone() with the canonical Olly voice spec. The formatter receives:
preserve_empathy flag — set when the narrower's response is deliberately empathic (frustrated or escalate_gp). Tells the formatter not to strip the acknowledgement line ("I know this can feel like a lot") and rewrite it as a generic informational sentence.Output: minimally-edited text + was_rewritten: true/false. Original returned untouched if the LLM call fails (fail-open).
Every narrower turn writes a row to triage.turns with the full capture under tool_args.v5_capture. Same payload is also stamped on the HTTP response as X-Triage-V5-Capture-B64 (base64-encoded JSON) so the chat client's debug-modal feature can render it per turn.
{
"turn": 2,
"v5_action": "pathway",
"v5_pw_code": "olly_msk_037",
"v5_confidence": 0.95,
"v5_capture": {
"extracted_fields": {
"presenting_complaint": "I twisted my ankle.",
"anatomical_region": "ankle",
"onset": "sudden",
"duration": "yesterday",
"trigger": "playing football",
"pain_score": 8,
"pain_character": null,
...
},
"domain": {"classification": "MSK", "confidence": 1.0},
"red_flag_mentions": ["trauma"],
"safeguarding_flags": [],
"confidence_overall": 0.98,
"context_completeness": 0.33,
"commit_path": "confidence_fast"
}
}
{
"turn": 3,
"v5_action": "ask",
"v5_capture": {
"extracted_fields": { ..., "mood_signal": "frustrated", ... },
"context_completeness": 0.20,
"gated_pw_code": "olly_back_mvp",
"gated_confidence": 0.85,
"frustration_count": 1,
"empathic_override": true
}
}
The chat client (/root/triage-rn/app/demo/chat/index.tsx) shows an info chip below every bot bubble. Tap it → modal renders the per-turn triage_meta JSON. Once the narrower commits a pathway, the chip swaps from ⓘ to PW <code> as a visible commit marker.
The BFF (app/api/chat+api.ts) base64-decodes the X-Triage-V5-Capture-B64 response header and forwards as triage_meta in the ?json=true body.
| Key | Type | TTL | Purpose |
|---|---|---|---|
| triage:v5:turns:<chat_id> | int | 1h | Turn counter (informational; no hard cap) |
| triage:v5:opts:<chat_id> | JSON | 1h | Cached differential options for free-text option-mapping |
| triage:v5:frust:<chat_id> | int | 1h | Consecutive frustration counter; resets to 0 on non-frustrated turn |
| triage:v5:prev_comp:<chat_id> | float | 1h | Previous turn's completeness — used to detect vague responses |
| triage:v5:lastq:<chat_id> | string | 1h | Last fact-finding question text — fed to rephrase prompt |
| triage:v5:rf:state:<chat_id> | JSON | 24h | RF sidecar (Ver-Stack 1) per-category position vector |
out_of_scope action. Greetings, vague messages, off-topic text all enter the fact-finding loop. False rejects are worse than redundant clarification turns.null. The only exceptions are the explicit-implication patterns (twist → sudden, "slept like a baby" → sleep_impact false, etc.) documented in the extraction prompt.| Env var | Default | Effect |
|---|---|---|
| V5_CONTEXT_COMPLETENESS_FLOOR | 0.80 | Branch 3b commit threshold. Lower = commit faster on partial info. |
| V5_HIGH_CONFIDENCE_THRESHOLD | 0.90 | Branch 3a fast-path threshold. Lower = fast-path commits earlier. |
| V5_FRUSTRATION_ESCALATION_LIMIT | 3 | Consecutive frustrated turns before escalate_gp. Lower = escalate sooner. |
| V5_MAX_CANDIDATES | 4 | Max pathway candidates surfaced in a differential question. |
| V5_CONFIDENCE_PROCEED | 0.85 | Branch 3b confidence threshold for the standard commit path. |
CHAT_ID=$(uuidgen)
KEY="olly_triage_grant_35_..." # fixture bearer
# Turn 1 — thin opener, expect ask
curl -sS -X POST http://127.0.0.1:4008/v5/chat/completions \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"model\":\"olly-v5\",\"chat_id\":\"$CHAT_ID\",
\"messages\":[{\"role\":\"user\",\"content\":\"I twisted my ankle\"}]}"
# Turn 2 — rich detail, expect commit_path: confidence_fast
curl -sS ... \
-d "{\"model\":\"olly-v5\",\"chat_id\":\"$CHAT_ID\",
\"messages\":[
{\"role\":\"user\",\"content\":\"I twisted my ankle\"},
{\"role\":\"assistant\",\"content\":\"...\"},
{\"role\":\"user\",\"content\":\"yesterday playing football, pain is 8/10\"}
]}"
| Path | Role |
|---|---|
| services/triage/src/triage/v5_chat_completions.py | Orchestrator. Routes ask / pathway / escape / escalate_gp / continue. Stamps X-Triage-V5-Capture-B64. |
| services/triage/src/triage/v5_narrow.py | narrow_step() — the ranking LLM call, the 5 branches, the capture extraction. |
| services/triage/src/triage/v5_tone_filter.py | Ver-Stack 2. Conversation-aware tone formatter with preserve_empathy flag. |
| services/triage/src/triage/v5_pin_and_handoff.py | Layer 2 bootstrap. Pins pathway + seeds v1 agent on commit turn. |
| services/triage/src/triage/v5_disposition_map.py | Layer 3. NHS terminal → Olly disposition mapping. |
| services/triage/src/triage/v5_rf_sidecar.py | Ver-Stack 1. Async red-flag observer. Accepts prior_mentions from capture. |
| /root/triage-rn/app/demo/chat/index.tsx | Chat client. ack-passthrough fix, debug-modal trigger. |
| /root/triage-rn/app/api/chat+api.ts | BFF. Decodes capture header → triage_meta in JSON response. |
| /root/triage-rn/src/components/olly/Bubbles.tsx | BotBubble with onDebugPress + pathwayId chip props. |