Triage
The triage service walks a member through a structured symptom assessment and returns a recommended care channel (999, nurse, GP, mental-health practitioner, or physiotherapist). It is a Python FastAPI app (port 4008, container olly-triage) that exposes an OpenAI chat-completions surface: callers POST a ChatCompletion body and get an assistant message back. The clinical content is the NHS 111 pathway corpus (held in the nhs111 Postgres schema on dev-2); triage walks those pathways and remaps each NHS terminal onto an Olly care channel. Sessions and turns persist in the triage Postgres schema.
As-built status
This page reflects what is wired today. The service has no Kafka integration and no REST CRUD surface over sessions: it is OpenAI-chat-completions-shaped only, and session rows are created as a side effect inside the chat handlers. There is no party_locator, no Keycloak/JWT, and no triage.session.completed event. The endpoint family is versioned v1 through v5; v5 is the only active version, v1 through v4 are deprecated-but-routed (see Endpoint versions).
Clinical-partner observations
Recall on red-flag pathways was 0.97 plus-or-minus 0.02 on the holdout set as of 2026-05-29.
What it owns
| Object | Type | Notes |
|---|---|---|
triage.sessions | Postgres table | One row per triage conversation: user, pathway, current state, status, disposition JSONB |
triage.turns | Postgres table | Append-only ordered turns per session (user / assistant / tool) |
triage.users | Postgres table | Fixture/Open WebUI users; bearer auth resolves against this |
triage.session_flags | Postgres table | Append-only red-flag observer audit ledger (written only by the RF sidecar) |
Field reference: column-level types and nullability are in the catalog. This page is the narrative; the load-bearing shapes are below.
Endpoint versions
main.py mounts five chat/narrow generations. v5 is the active orchestrator; v1 through v4 stay in the codebase but are deprecated as public endpoints. Any external HTTP hit to a deprecated path gets Deprecation: true (RFC 8594), a Warning: 299 header, and X-Triage-Deprecated-Version / X-Triage-Active-Version (set by the _deprecated_endpoint_warning middleware in main.py). v1 is also v5's continuing-turn backend, called in-process (not over HTTP), so those internal calls do not trip the middleware.
| Endpoint | Status | Role |
|---|---|---|
POST /v5/chat/completions (+ /v5/narrow, plus the v5_* prompt / lever / trace / search APIs) | Active | Current orchestrator. First-turn pathway picking via v5_narrow.narrow_step, pinning via v5_pin_and_handoff, disposition map, tone formatter, RF sidecar. Continuing turns forward in-process to v1. |
POST /v1/chat/completions | Deprecated; load-bearing | PydanticAI agent runtime. Still backs v5 on continuing turns. |
POST /v2/chat/completions (model olly-mvp) | Deprecated | Olly-owned MSK back-pain decision tree (olly_back_mvp). Retained for the MVP-BP e2e suite. |
POST /v3/narrow | Deprecated | Structured pathway-narrowing primitive (returns {action, ...} JSON, not a chat message). Superseded by v5_narrow. |
POST /v4/chat/completions | Deprecated | Earlier orchestrator; superseded by v5. No callers should be added. |
The public base is https://triage.dev.hiolly.com/triage-api/; nginx rewrites ^/triage-api/(.*) to /$1, so the canonical call is POST /triage-api/v5/chat/completions.
v5 request and routing
V5Request is a standard OpenAI body plus a required chat_id. chat_id must be a UUID and pins the call to one session row: the handler does uuid.UUID(req.chat_id) and returns 400 chat_id must be a uuid on a missing or non-UUID value. There is no latest-active-session fallback in v5 (v1 has one for a missing chat_id; v5 does not).
v5 orchestrates three layers per turn:
| Layer | Component | Purpose |
|---|---|---|
| 1 | v5_narrow.narrow_step | Capture chief complaint, pick a pathway from the nhs111.pathways candidate pool |
| 2 | v5_pin_and_handoff (first turn) / v1 agent (continuing turns) | Pin pathway_id, then walk the pathway dialogue |
| 3 | disposition_map.map_to_olly | Remap the NHS terminal onto an Olly care channel |
Two vertical sidecars run alongside the main path: Ver-Stack 1 is the RF (red-flag) observer (v5_rf_sidecar, async fire-and-forget on every user turn), and Ver-Stack 2 is the tone formatter (v5_tone_formatter, rewrites first-turn assistant text).
v5 response headers
| Header | Value |
|---|---|
X-Triage-Session-Id | The session UUID (echo of chat_id) |
X-Triage-Routed-Via | Always v5 |
X-Triage-V5-Pathway | Resolved pathway code |
X-Triage-V5-Layer-Action | Current layer action (continue, ask, escape, off_domain, escalate_gp, funnel:<state>, ...) |
X-Triage-V5-Capture-B64 | Base64-encoded narrower capture JSON; lets a BFF render diagnostics without a second fetch |
X-Triage-Tone-Rewritten | true, set only when the tone formatter actually rewrote the message |
Two more appear on specific paths: X-Triage-V5-Funnel-Rationale (funnel state) and X-Triage-V5-Domain (off-domain detection). There is no X-Triage-Tone-Score (it does not exist anywhere in the service). X-Triage-Is-Terminal, X-Triage-Care-Type, and X-Triage-Disposition-Title are emitted by v1 only, and X-Triage-Care-Type carries the Olly care-type enum (emergency / nurse / gp / mental_health / physio), not self-care/urgent/ed.
Debug surface
There is no session CRUD API. The only session-scoped HTTP routes are the debug and judgement endpoints (under /triage-api/v1/... publicly):
| Method | Path | Returns |
|---|---|---|
GET | /v1/sessions/{session_id}/debug | Merged session + turns + tool calls + Langfuse trace + RF audit |
GET | /v1/sessions/{session_id}/judgement | Latest persisted LLM judgement (or null) |
POST | /v1/sessions/{session_id}/judgement | Run and persist a judgement |
GET | /healthz, /readyz | Liveness / readiness |
Prefer the MCP tool triage_session_debug over hitting /debug directly: it merges the same payload (debug.py). _group_observations_by_turn matches each Langfuse trace to a DB turn by content, not by ordinal, so a dropped trace never slides onto the wrong turn.
Data model
Schema: triage (dev: dev-2 Postgres 10.0.1.2:5432).
sessions
| Column | Type | Notes |
|---|---|---|
id | UUID PK | gen_random_uuid(); the UUID is the handle (there is no separate locator) |
user_id | UUID | FK → triage.users(id) |
pathway_id | text | NHS/Olly pathway code, set once pinned |
current_state_id | text | Current node in the pathway walk |
sex | text | Female / Male |
age | integer | |
chief_complaint | text | Original symptom string |
status | text | active (default) / completed / abandoned (CHECK-constrained) |
disposition | jsonb | Written on completion; shape below |
created_at | timestamptz | |
completed_at | timestamptz | Set when status moves to completed |
turns
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
session_id | UUID | FK → triage.sessions(id) |
role | text | user / assistant / tool (CHECK-constrained) |
content | text | |
state_id | text | Pathway node this turn happened at |
tool_name | text | Tool turns only |
tool_args | jsonb | Tool call arguments |
tool_result | jsonb | Tool call result |
created_at | timestamptz |
Tool data lives across tool_name / tool_args / tool_result (there is no single tool_calls blob and no turn_index column).
disposition JSONB
On completion the v1 handler runs UPDATE triage.sessions SET status='completed', completed_at=NOW(), disposition=%s::jsonb WHERE id=%s AND status='active'. The blob comes from the Disposition model plus mapping audit:
{
"title": "Book a call with an Olly nurse",
"urgency": "emergency | urgent | non_urgent | self_care",
"advice": "...",
"care_type": "emergency | nurse | gp | mental_health | physio",
"nhs_original": { "title": "...", "urgency": "..." },
"mapping": { "nhs_title": "...", "olly_care_type": "...", "msk_fcp_overlay": false, "...": "..." }
}urgency is the clinical band (preserved from the NHS terminal). care_type is the Olly channel chosen by disposition_map.map_to_olly. nhs_original keeps the NHS-canonical title/urgency, and mapping is the audit dict recording why the channel was chosen (mental-health override, MSK FCP overlay, dental special case, emergency passthrough). There is no top-level severity, specialist_referral, safety_net, or pathway field.
NHS-to-Olly disposition mapping
disposition_map.map_to_olly is the bridge from NHS 111's service set (pharmacist, dentist, 111 nurse, GP, A&E) to Olly's five channels. Load-bearing rules (disposition_map.py):
- 999 /
urgency='emergency'is never remapped. Safety floor. - Mental-health pathways (
MENTAL_HEALTH_PW_CODES, an NHS-taxonomy-derived set) route every non-999 terminal tomental_health. - MSK FCP overlay: a routine (non-emergency, non-urgent) terminal on a pathway in
MSK_FCP_PATHWAY_CODES(lower-back, leg/knee/ankle/foot, shoulder, all atraumatic) is overlaid with a First Contact Physiotherapist (physio) disposition. Traumatic-mechanism back/limb pathways are deliberately excluded. This overlay is a v1 patch; v2 routes MSK natively. - Otherwise the NHS title is bucketed by regex: GP/doctor →
gp; dentist / pharmacist / 111 nurse / self-care →nurse; anything unknown →nurse(logged at WARN). - Olly-owned pathways (
pw_codeprefixedolly_) ship Olly-styled titles already, so the rewrite is a no-op passthrough for them.
Red-flag observer sidecar
The RF observer (v5_rf_sidecar / rf_sidecar) fires async on every user turn via asyncio.create_task(...). It is observe-only: it never changes disposition or routing. It maintains a per-session position vector over red-flag categories in Valkey (triage:rf:state:<chat_id>, 24h TTL) so cumulative patterns get caught even when no single message trips a Layer-0 keyword. Every non-zero delta lands on three surfaces: an append-only triage.session_flags row (canonical), an OTel span (Langfuse search), and the Valkey blob (next-turn lookup). triage.session_flags is append-only with composite PK (session_id, turn_index, category) and is written only by the sidecar.
This is distinct from Layer 0: the red_flag_check substring scan inside each endpoint still short-circuits to a 999 terminal on an unambiguous keyword. The sidecar runs in parallel and does not block the response.
Authentication
Bearer API key, not Keycloak/JWT. auth.resolve_user_from_bearer takes the bearer token, SHA-256-hashes it, and looks the hash up against triage.users.api_key_hash. These are Open WebUI API keys seeded as fixture users (alice_5, clare_35, ...); the fixture users are the auth model. There is no JWKS validation, no Keycloak client, and no party_locator claim anywhere in the service. A missing or unknown token returns 401.
Events
The triage service emits no Kafka events. It has no Kafka producer, consumer, topic, or outbox; its compose block has no Kafka bootstrap and no Kafka in depends_on. Completion is purely an in-DB UPDATE of triage.sessions. No downstream service consumes anything from triage.
Planned, not built
A care-episode handoff (triage completion opening a Care episode, or a Notifications summary) is a plausible future integration but is not implemented: no event is emitted and no service subscribes. Treat any care/notifications coupling as a roadmap item, not current behaviour.
Trace export
Every tool call emits OTel spans to Langfuse via the OTel collector (OTEL_EXPORTER_OTLP_ENDPOINT=http://10.0.1.2:4317, LANGFUSE_HOST=https://langfuse.dev.hiolly.com). No subscriber-side change is needed: use triage_session_debug (MCP) or GET /triage-api/v1/sessions/{id}/debug to retrieve the merged session plus trace. FastAPI auto-instrumentation is wired once via FastAPIInstrumentor.instrument_app(app) in main.py (never the global .instrument() form, which double-roots and orphans spans).
FHIR clinical record
Beyond the session turns, triage projects analysed media and ingested clinical documents into a FHIR R4 clinical record. This is the only FHIR surface in Olly today; there is no HAPI server and claims/eligibility do not touch it.
- Store: a GCP Healthcare API FHIR store,
projects/olly-platform-dev/locations/europe-west2/datasets/olly-fhir/fhirStores/triage(TRIAGE_FHIR_STORE). Regioneurope-west2= UK data residency. Auth is keyless via Workload Identity Federation (TRIAGE_WIF_*), ADC as fallback. WhenTRIAGE_FHIR_STOREis unset the push is a logged no-op, so local/CI runs degrade gracefully. - Write path:
fhir_project.build_bundle/fhir_record_projectproject a capture into a FHIR R4 transaction Bundle - aPatient(identified by the member's Olly locator, plus NHS number when present), anEncounter(the session), a provisional problem-listConditionfrom the presenting complaint,Observations per captured field, and aDocumentReferencefor the media asset.fhir_client.push_bundleexecutes it against the store; onePatientis reused per member. The push is a side effect, never on the critical path - a failed push does not break the triage turn. - Read-back:
GET /v5/recordsreturns the member's record as a portable FHIRBundle(UK GDPR/DPA Arts. 15/20, Right of Access + Portability);DELETE /v5/recordspurges the member's FHIR compartment (Art. 17, FHIR-scope only). Both are behind the trusted-service auth branch (X-Internal-Service+X-On-Behalf-Of=party_locator).
Not yet coded / not yet interoperable
Clinical concepts are stored as free-text CodeableConcept.text with zero SNOMED / LOINC / dm+d coding - terminology binding is a known unbuilt gap. The store is FHIR-shaped and live, but not an interoperability layer: no CMS-9115 Patient-Access / Provider-Directory / Payer-to-Payer APIs, no SMART-on-FHIR, no $export. 🚧 Those are target-state (Phase P2), 0 code today.
Dependencies
| Dependency | Purpose | Failure mode |
|---|---|---|
Postgres triage (dev-2 10.0.1.2:5432) | sessions / turns / users / session_flags | Hard fail: service starts but cannot serve requests |
GCP Healthcare FHIR store (TRIAGE_FHIR_STORE, europe-west2) | FHIR R4 clinical record (media + document ingest, /v5/records) | Degraded: push is a logged no-op / caught error; triage turn unaffected |
Postgres nhs111 (dev-2 10.0.1.2:5432) | NHS pathway corpus + olly_back_mvp tree | Hard fail: no pathway to walk |
Valkey (dev-2 10.0.1.2:16379) | Session/history cache, RF position vector, v3 turn counters | Degraded: falls back to DB; RF cumulative state is lost |
Google Gemini (GEMINI_API_KEY; BAO path secret/data/triage-agent/gemini) | Agent reasoning, narrowing, gemini-flash classifiers, v5 orchestration | Hard fail: terminal session with generic safety net |
Langfuse (LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY=pk-olly-nhs111-triage, LANGFUSE_SECRET_KEY) | LLM trace export | Degraded: traces drop silently; sessions unaffected |
OTel collector (10.0.1.2:4317) | Span shipping to Langfuse | Degraded: local logs only |
The LLM provider is Google Gemini. There are no LLM_BASE_URL / LLM_API_KEY env vars and no OpenRouter reference in the service.
Invariants
- The session
idUUID is the handle. There is no separatelocator, and clients pass it aschat_id(required UUID on v5). statusisactive → completed | abandoned. The completionUPDATEis guarded byWHERE ... AND status='active', so a completed session is not re-completed.turnsare append-only;triage.session_flagsrows are append-only with a composite PK and are written only by the RF sidecar.- 999 /
urgency='emergency'dispositions are never remapped to a softer channel. - The RF sidecar is observe-only: it records risk trajectory but does not change routing or disposition.
- v1 through v4 return RFC 8594
Deprecation/Warningheaders to external callers; only v5 is the supported public surface.
Non-goals
- Does not make a clinical diagnosis (triage only, not Dx).
- Does not emit events or hand off to Care/Notifications (no such integration is wired).
- Does not call external EHR/interoperability systems (no CMS-9115, SMART-on-FHIR, or HL7v2 in/out). The FHIR record it writes is Olly's own GCP Healthcare store (below), not a third-party EHR.
Caveats
- OpenAI-shaped only. There is no create/respond/complete/turns REST API; session rows are a side effect of the chat handlers.
- Deprecated endpoints still answer. v1 through v4 respond but flag themselves as deprecated; new callers must use v5.
gemini-3.5-flashis not a real model. The classifiers use the gemini-flash family; v2's model id is the stringolly-mvp.- MSK FCP overlay is a v1 patch. Do not extend
MSK_FCP_PATHWAY_CODESfor new regions; build a native v2 tree instead.
