Triage v5 — narrower logic & capture fields

live supersedes v4 backend: services/triage/src/triage/v5_narrow.py  Reference for the Layer-1 capture + routing decision in v5.

TL;DR

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

The 17 capture fields

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

FieldTypeTierWhat it captures
presenting_complainttextalways-filledThe chief complaint in the patient's words. Excluded from completeness math (would inflate the score).
anatomical_regionenumCRITICALBody region (back_lower, knee, chest, ear, …). Required for routing — most pathways are region-scoped.
onsetenumCRITICALsudden | gradual | unknown. Implicit-fill: twist/fall/knock → sudden; "crept on" → gradual.
durationtextcountedHow long it's been going on. Short free text ("three days", "since Tuesday").
triggertextCRITICALWhat caused it / what they were doing. "after lifting boxes", "playing football".
pain_scoreint 0–10CRITICALNumeric severity. Only filled if the patient gave a number — never guess.
pain_characterenumcountedsharp | dull | burning | aching | throbbing | stabbing | unknown.
radiationtextcountedWhere the pain spreads. "down the left leg", "into the jaw".
function_impactenumCRITICALnone | mild | moderate | severe | unable. Implicit-fill: "can't stand" → unable.
sleep_impactboolcountedtrue / false. Implicit: "slept like a baby" → false; "tossed and turned" → true.
work_impactboolnice-to-haveImplicit: "called in sick" → true; "still working" → false.
mood_signalenumcountedlow | anxious | frustrated | neutral | unknown. Frustrated triggers special handling — see below.
worry_scoreint 0–10nice-to-haveOnly if patient stated a number.
prior_episodesboolcountedImplicit: "this happened last year" → true.
self_treatmenttextcounted"paracetamol", "ice pack", "rest". Free text.
what_they_thinktextnice-to-haveThe patient's own theory ("I think I've slipped a disc"). Captured for audit, never echoed back per spec.
red_flag_mentions[]listCRITICALTokens 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[]listtag-onlysuicidal_ideation, self_harm, domestic_abuse, etc. Surfaced in audit; routing override deferred pending clinical sign-off.
CRITICALrouting-essential — gate the confidence-fast commit (next section)
countedcontributes to 80% completeness score (15 of 16 non-PC fields)
nice-to-haveaudit + downstream consumer use (FCP handover, population health)

The decision flow

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:

Branch 1 — Escalate to GP (frustration limit hit)

frustration_counter ≥ V5_FRUSTRATION_ESCALATION_LIMIT (default 3)
→ action 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.

Branch 2 — Empathic ask (frustration counter ≥ 1, < limit)

extracted_fields.mood_signal == "frustrated"
→ action 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.

Branch 3a — Confidence-aware fast-path commit

top.confidence ≥ V5_HIGH_CONFIDENCE_THRESHOLD (0.90) AND critical_fields_filled
→ action 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

Branch 3b — Standard completeness-gate commit

top.confidence ≥ V5_CONFIDENCE_PROCEED (0.85) AND completeness ≥ V5_CONTEXT_COMPLETENESS_FLOOR (0.80)
→ action pathway, commit_path: completeness_gate. Standard path when patient gives a rich initial message.

Branch 4 — Ask for more info (with rephrase if vague)

top.confidence ≥ 0.85 BUT completeness < 0.80 AND critical fields incomplete
→ action 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.

Branch 5 — Differential question (multi-candidate disambiguation)

top.confidence < 0.85 AND multiple plausible candidates
→ action 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.

Patient archetypes (what triggers what)

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

Tone formatter (Ver-Stack 2)

Every narrower response is post-processed by enforce_tone() with the canonical Olly voice spec. The formatter receives:

Output: minimally-edited text + was_rewritten: true/false. Original returned untouched if the LLM call fails (fail-open).

Audit trail

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.

Example tool_args.v5_capture for a confidence_fast commit
{
  "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"
  }
}
Example tool_args.v5_capture for a frustrated turn (Branch 2)
{
  "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
  }
}

Frontend debug modal

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.

Valkey state — per chat_id

KeyTypeTTLPurpose
triage:v5:turns:<chat_id>int1hTurn counter (informational; no hard cap)
triage:v5:opts:<chat_id>JSON1hCached differential options for free-text option-mapping
triage:v5:frust:<chat_id>int1hConsecutive frustration counter; resets to 0 on non-frustrated turn
triage:v5:prev_comp:<chat_id>float1hPrevious turn's completeness — used to detect vague responses
triage:v5:lastq:<chat_id>string1hLast fact-finding question text — fed to rephrase prompt
triage:v5:rf:state:<chat_id>JSON24hRF sidecar (Ver-Stack 1) per-category position vector

Hard rules

Tuning knobs (env-configurable)

Env varDefaultEffect
V5_CONTEXT_COMPLETENESS_FLOOR0.80Branch 3b commit threshold. Lower = commit faster on partial info.
V5_HIGH_CONFIDENCE_THRESHOLD0.90Branch 3a fast-path threshold. Lower = fast-path commits earlier.
V5_FRUSTRATION_ESCALATION_LIMIT3Consecutive frustrated turns before escalate_gp. Lower = escalate sooner.
V5_MAX_CANDIDATES4Max pathway candidates surfaced in a differential question.
V5_CONFIDENCE_PROCEED0.85Branch 3b confidence threshold for the standard commit path.

Try it

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

Files

PathRole
services/triage/src/triage/v5_chat_completions.pyOrchestrator. Routes ask / pathway / escape / escalate_gp / continue. Stamps X-Triage-V5-Capture-B64.
services/triage/src/triage/v5_narrow.pynarrow_step() — the ranking LLM call, the 5 branches, the capture extraction.
services/triage/src/triage/v5_tone_filter.pyVer-Stack 2. Conversation-aware tone formatter with preserve_empathy flag.
services/triage/src/triage/v5_pin_and_handoff.pyLayer 2 bootstrap. Pins pathway + seeds v1 agent on commit turn.
services/triage/src/triage/v5_disposition_map.pyLayer 3. NHS terminal → Olly disposition mapping.
services/triage/src/triage/v5_rf_sidecar.pyVer-Stack 1. Async red-flag observer. Accepts prior_mentions from capture.
/root/triage-rn/app/demo/chat/index.tsxChat client. ack-passthrough fix, debug-modal trigger.
/root/triage-rn/app/api/chat+api.tsBFF. Decodes capture header → triage_meta in JSON response.
/root/triage-rn/src/components/olly/Bubbles.tsxBotBubble with onDebugPress + pathwayId chip props.