AI Assistant — Visit-Level Wiring and Context Passing
Audiences: developer, internal, doctor
When a clinician opens the AI assistant inside a Visit page, ddx-api links the new ChatSession (TUCAN) or FlowAgentSession to that Visit via
visitId+fhirEncounterId+LinkedEntityType.VISIT, then resolves the visit's encounter, recordings, and timeline throughgetVisitAgentContextso every tool call and every voice turn sees the patient's current encounter without the LLM having to ask.
Business Purpose
Two clinical realities collide: (1) the doctor is inside a visit and expects the assistant to already know what visit, which patient, which appointment, and what the last note said; (2) the visit lives in FHIR (numeric Encounter ID) while sessions live in Prisma (UUIDs). Voice surfaces (FlowAgent, TUCAN Operator) make it worse because the patient ID may arrive as a FHIR id and must resolve to a UUID before persistence. This wiring layer is the deterministic bridge: it fills the agent's "where am I?" context once at session creation, persists the linkage on indexed FK columns, and re-resolves at every turn from the canonical FHIR encounter — so the assistant cannot drift into the wrong patient or visit between turns.
Audiences
- Doctor: Opens AI assistant on a visit → the assistant already knows the visit + patient + recordings; no "which patient are we talking about?" round-trip.
- Developer/partner: Single DTO contract —
CreateChatSessionDto.visitId(UUID or FHIR Encounter ID) triggers the full cascade (FHIR ID resolution, metadata stamping, indexed FK column, system-prompt injection). - Internal (ops/support): Visit linkage is on indexed FK columns (
ChatSession.visitId,ChatSession.linkedEntityType,ChatSession.linkedEntityId) so support queries ("show me all assistant sessions on visit X") are O(log n). - Investor: Same context-resolution path used by chat + voice + future SDK calls — one boundary to harden, one boundary to audit for PHI leakage.
Architecture
ddx-web visit page
│
│ POST /agents/sessions { visitId, patientId, agentType }
│ POST /flowagent/sessions { visitId, … }
▼
ddx-api SessionService
│
├── Build contextMetadata
│ - type / agentType / practitionerId / practitionerName / locale
│ - visitId (UUID or FHIR Encounter ID, either is accepted)
│ - appointmentId / intakeId / documentId (whichever applies)
│ - linkedPatient { uuid, fhirId, linkedAt }
│
├── If visitId provided AND fhirEncounterId not in metadata
│ → Prisma.visit.findFirst({ where: { OR: [{id: visitId}, {fhirEncounterId: visitId}] } })
│ → contextMetadata.fhirEncounterId = visitRow.fhirEncounterId
│
├── linkedEntityType = LinkedEntityType.VISIT (or INTAKE / APPOINTMENT / DOCUMENT)
│ linkedEntityId = dto.visitId ?? dto.intakeId ?? dto.appointmentId ?? documentId
│
├── Repository.create({ visitId, linkedEntityType, linkedEntityId,
│ systemPrompt: getSystemPrompt(…contextMetadata),
│ metadata: contextMetadata, … })
│
▼
Per turn — SessionContextBuilder (context/session.context.ts:131-147)
if session.visitId OR metadata.visitId:
visit = Prisma.visit.findFirst({ where: { OR: [{id}, {fhirEncounterId}] } })
identity.visit = { visitId: visit.id, … }
│
▼
TUCAN tools (get_visit_summary, get_visit_context, etc.)
→ VisitContextAdapter.getVisitSummary
→ VisitsService.getVisitAgentContext(visitId, clinicId)
→ resolveVisitId → visitCrudService.getVisit
→ returns { visitId, summary, metadata, recordings[] }
Tech Stack & Choices
- DTO contract:
CreateChatSessionDto.visitIdaccepts UUID OR FHIR Encounter ID (session.service.ts:295,:310-320). Resolution is centralised — callers never need to know which type they hold. - Persistence: dual storage — JSON metadata for human readability + indexed FK columns (
visitId,linkedEntityType,linkedEntityId) for query performance (session.service.ts:322-333,:364). - Linkage taxonomy:
LinkedEntityType.VISIT > INTAKE > APPOINTMENT > DOCUMENT(priority order at:324-332). Only the highest-priority entity wins. - Per-turn re-resolution:
SessionContextBuilder(context/session.context.ts:70,:131-147) re-queries the visit row every turn so a renamed/cancelled/merged visit propagates immediately. - Tool surface: TUCAN tools see the visit via
TucanVisitService.getVisitSummaryadapter (visit-context.adapter.ts:33-35), which delegates toVisitsService.getVisitAgentContext(visits.service.ts:464-499). - Returned shape:
{ visitId, summary, metadata, recordings[] }— recordings include id, name, createdAt, practitioner, duration, timeline (the timeline string is what tools render for the doctor). - Why dual storage? Metadata captures intent + audit; FK columns serve queries. Reading metadata alone would force a JSON scan; reading FK alone would lose the linkedAt timestamp + linkage rationale.
- Why FHIR re-resolution per turn instead of caching? Encounters can be amended, merged, or partition-moved. Caching across turns risks the assistant acting on a stale visit. The Prisma query is index-backed and cheap.
- Why this lives in ddx-api, not livekit-flowagent-ts? ddx-api owns FHIR + Prisma. The agent runtime is a stateless consumer; centralising visit resolution prevents drift between chat and voice.
Data Flow
- Session create (chat): ddx-web →
POST /agents/sessions { visitId, patientId, agentType }→SessionService.createSessionbuildscontextMetadatawithvisitId+ optionallyfhirEncounterId+linkedPatient→ repository insertsChatSessionwithvisitId(:364),linkedEntityType(:324),linkedEntityId(:333). - Session create (voice / FlowAgent): ddx-web →
POST /flowagent/sessions→FlowAgentSessionService.create(ddx-api/src/ai/flowagent/services/session.service.ts) also creates a linked ChatSession (TUCAN dispatch route) + publishes SSE onSSEChannels.session(...)— that linkage is what lets the voice session reach back into the TUCAN tool set later. - System prompt injection:
getSystemPrompt(agentType, patientUuid, practitionerId, practitionerName, organizationId, currentDateTime, locale, contextMetadata)(:348-357) — visit + patient + practitioner are stamped into the prompt at create time so the first turn already has context. - First turn:
SessionContextBuilder.buildruns (context/session.context.ts:131-147) → ifsession.visitIdis present, queriesPrisma.visit.findFirst({ where: { OR: [{id}, {fhirEncounterId}] } })and injectsidentity.visit(:147). - Tool call (e.g.
get_visit_summary): TUCAN routes toVisitContextAdapter.getVisitSummary(:33-35) →VisitsService.getVisitAgentContext(:464) → resolves to FHIR Encounter ID (:481) →visitCrudService.getVisit→ returns metadata + recordings array. Logs are[AgentContext] Visit X: status=…, recordings=N(:497-499). - Voice turn: FlowAgent step has the same visit linkage (via its linked ChatSession). When the LLM calls a visit-aware tool (
get_visit_summary,get_visit_context,list_recordings_for_visit), it hits the same adapter and gets the same data — chat and voice converge. - Frontend pickup: ddx-web (Medical Cards — see medical-cards.md) reads
getVisitAgentContextvia the visit controller (visits.controller.ts:738) for UI rendering.
Implicated Code
MANDATORY: ≥3 file:line citations.
ddx-api/src/ai/tucan/session/session.service.ts:285-307—contextMetadatabuild (locale + visitId + appointmentId + intakeId + linkedPatient).ddx-api/src/ai/tucan/session/session.service.ts:309-320— FHIR Encounter ID resolution when only the UUID (or only the FHIR id) was passed.ddx-api/src/ai/tucan/session/session.service.ts:322-333—LinkedEntityTypecascade (VISIT > INTAKE > APPOINTMENT > DOCUMENT).ddx-api/src/ai/tucan/session/session.service.ts:341-364— repository.create persistingvisitId,linkedEntityType,linkedEntityId, system prompt.ddx-api/src/ai/tucan/context/session.context.ts:70— Prisma select includingvisitId: true.ddx-api/src/ai/tucan/context/session.context.ts:131-147— per-turn visit lookupOR: [{id}, {fhirEncounterId}]→identity.visit.ddx-api/src/ai/tucan/context/adapters/visit-context.adapter.ts:33-35—getVisitSummaryadapter delegates togetVisitAgentContext.ddx-api/src/clinical/visits/visits.service.ts:464-499—getVisitAgentContextimpl (resolveVisitId, visitCrudService.getVisit, recordings extraction).ddx-api/src/clinical/visits/visits.controller.ts:738— controller endpoint backing the same service for ddx-web pickup.ddx-api/src/ai/flowagent/services/session.service.ts:1-38— FlowAgent session also creates a linked ChatSession for TUCAN dispatch + SSE.
Operational Notes
visitIdaccepts UUID OR FHIR Encounter ID — do not assume one or the other in callers. Resolution is the service's job (session.service.ts:310-320).- Indexed FK columns are the source of truth for queries — use
where: { visitId: X }in admin tools, NOT a metadata JSON scan. - Per-turn re-resolution is intentional — do not cache the visit row in agent memory across turns. Encounters move (partition merges, FHIR amendments), and a stale visit would silently corrupt subsequent tool calls.
- FlowAgent + ChatSession dual session: starting a FlowAgent voice session also creates a TUCAN ChatSession (
flowagent/services/session.service.ts:1-15). Both sharevisitId. Cleaning up one without the other leaves an orphan — termination must hit both. - Pitfall — UUID vs FHIR ID drift: tests at
ddx-api/src/clinical/visits/__tests__/visits.service.spec.ts:645-668cover the UUID auto-detect path. Add a regression test whenever the resolver is touched. - Pitfall — getVisitAgentContext recordings array: empty when the visit has no recordings; never null. Tool code must guard for
recordings.length === 0, notrecordings == null. @deprecatednotice: passing FHIR Encounter ID directly is allowed for legacy callers — UUID is the recommended primary identifier (visits.service.ts:460-466).- PHI boundary:
linkedPatient.uuidis the user UUID,linkedPatient.fhirIdis the FHIR Patient ID. Tools that need to call HAPI FHIR use the latter; tools that need to talk to ddx-api use the former.
Related Topics
- Medical Cards (W2) — ddx-web consumer of
getVisitAgentContext - TUCAN Context Builder (W3) —
session.context.tsis part of this system - TUCAN Dispatcher (W3) — receives the assembled identity + visit
- FlowAgent Scenarios — voice sessions linked through the same wiring
- Voice Commands — NAVIGATE intents land at this layer to switch the active visit
- Voxial LLM — the model sees the visit-aware system prompt produced here