Skip to content
Updated Aug 11, 2026

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

ObjectTypeNotes
triage.sessionsPostgres tableOne row per triage conversation: user, pathway, current state, status, disposition JSONB
triage.turnsPostgres tableAppend-only ordered turns per session (user / assistant / tool)
triage.usersPostgres tableFixture/Open WebUI users; bearer auth resolves against this
triage.session_flagsPostgres tableAppend-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.

EndpointStatusRole
POST /v5/chat/completions (+ /v5/narrow, plus the v5_* prompt / lever / trace / search APIs)ActiveCurrent 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/completionsDeprecated; load-bearingPydanticAI agent runtime. Still backs v5 on continuing turns.
POST /v2/chat/completions (model olly-mvp)DeprecatedOlly-owned MSK back-pain decision tree (olly_back_mvp). Retained for the MVP-BP e2e suite.
POST /v3/narrowDeprecatedStructured pathway-narrowing primitive (returns {action, ...} JSON, not a chat message). Superseded by v5_narrow.
POST /v4/chat/completionsDeprecatedEarlier 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:

LayerComponentPurpose
1v5_narrow.narrow_stepCapture chief complaint, pick a pathway from the nhs111.pathways candidate pool
2v5_pin_and_handoff (first turn) / v1 agent (continuing turns)Pin pathway_id, then walk the pathway dialogue
3disposition_map.map_to_ollyRemap 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

HeaderValue
X-Triage-Session-IdThe session UUID (echo of chat_id)
X-Triage-Routed-ViaAlways v5
X-Triage-V5-PathwayResolved pathway code
X-Triage-V5-Layer-ActionCurrent layer action (continue, ask, escape, off_domain, escalate_gp, funnel:<state>, ...)
X-Triage-V5-Capture-B64Base64-encoded narrower capture JSON; lets a BFF render diagnostics without a second fetch
X-Triage-Tone-Rewrittentrue, 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):

MethodPathReturns
GET/v1/sessions/{session_id}/debugMerged session + turns + tool calls + Langfuse trace + RF audit
GET/v1/sessions/{session_id}/judgementLatest persisted LLM judgement (or null)
POST/v1/sessions/{session_id}/judgementRun and persist a judgement
GET/healthz, /readyzLiveness / 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

ColumnTypeNotes
idUUID PKgen_random_uuid(); the UUID is the handle (there is no separate locator)
user_idUUIDFK → triage.users(id)
pathway_idtextNHS/Olly pathway code, set once pinned
current_state_idtextCurrent node in the pathway walk
sextextFemale / Male
ageinteger
chief_complainttextOriginal symptom string
statustextactive (default) / completed / abandoned (CHECK-constrained)
dispositionjsonbWritten on completion; shape below
created_attimestamptz
completed_attimestamptzSet when status moves to completed

turns

ColumnTypeNotes
idUUID PK
session_idUUIDFK → triage.sessions(id)
roletextuser / assistant / tool (CHECK-constrained)
contenttext
state_idtextPathway node this turn happened at
tool_nametextTool turns only
tool_argsjsonbTool call arguments
tool_resultjsonbTool call result
created_attimestamptz

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:

json
{
  "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 to mental_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_code prefixed olly_) 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). Region europe-west2 = UK data residency. Auth is keyless via Workload Identity Federation (TRIAGE_WIF_*), ADC as fallback. When TRIAGE_FHIR_STORE is unset the push is a logged no-op, so local/CI runs degrade gracefully.
  • Write path: fhir_project.build_bundle / fhir_record_project project a capture into a FHIR R4 transaction Bundle - a Patient (identified by the member's Olly locator, plus NHS number when present), an Encounter (the session), a provisional problem-list Condition from the presenting complaint, Observations per captured field, and a DocumentReference for the media asset. fhir_client.push_bundle executes it against the store; one Patient is 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/records returns the member's record as a portable FHIR Bundle (UK GDPR/DPA Arts. 15/20, Right of Access + Portability); DELETE /v5/records purges 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

DependencyPurposeFailure mode
Postgres triage (dev-2 10.0.1.2:5432)sessions / turns / users / session_flagsHard 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 treeHard fail: no pathway to walk
Valkey (dev-2 10.0.1.2:16379)Session/history cache, RF position vector, v3 turn countersDegraded: 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 orchestrationHard fail: terminal session with generic safety net
Langfuse (LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY=pk-olly-nhs111-triage, LANGFUSE_SECRET_KEY)LLM trace exportDegraded: traces drop silently; sessions unaffected
OTel collector (10.0.1.2:4317)Span shipping to LangfuseDegraded: 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 id UUID is the handle. There is no separate locator, and clients pass it as chat_id (required UUID on v5).
  • status is active → completed | abandoned. The completion UPDATE is guarded by WHERE ... AND status='active', so a completed session is not re-completed.
  • turns are append-only; triage.session_flags rows 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/Warning headers 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-flash is not a real model. The classifiers use the gemini-flash family; v2's model id is the string olly-mvp.
  • MSK FCP overlay is a v1 patch. Do not extend MSK_FCP_PATHWAY_CODES for new regions; build a native v2 tree instead.

Olly Health Insurance Platform