07-voice-and-telemedwave: W4filled23 citations

AI Assistant — Visit-Level Wiring and Context Passing

Audiences: developer, internal, doctor

AI Assistant — Visit-Level Wiring and Context Passing

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 through getVisitAgentContext so 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

Rendering diagram…

ddx-api SessionService — session creation

StepDetail
Build contextMetadatatype / 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 metadataPrisma.visit.findFirst({ where: { OR: [{id: visitId}, {fhirEncounterId: visitId}] } })contextMetadata.fhirEncounterId = visitRow.fhirEncounterId
Link the entitylinkedEntityType = LinkedEntityType.VISIT (or INTAKE / APPOINTMENT / DOCUMENT); linkedEntityId = dto.visitId ?? dto.intakeId ?? dto.appointmentId ?? documentId
PersistRepository.create({ visitId, linkedEntityType, linkedEntityId, systemPrompt: getSystemPrompt(…contextMetadata), metadata: contextMetadata, … })

Per turn — SessionContextBuilder (context/session.context.ts:131-147)

ts
if (session.visitId OR metadata.visitId) {
  visit = Prisma.visit.findFirst({ where: { OR: [{id}, {fhirEncounterId}] } })
  identity.visit = { visitId: visit.id, … }
}

TUCAN tool resolution chain

#Hop
1VisitContextAdapter.getVisitSummary
2VisitsService.getVisitAgentContext(visitId, clinicId)
3resolveVisitIdvisitCrudService.getVisit
4returns { visitId, summary, metadata, recordings[] }

Tech Stack & Choices

  • DTO contract: CreateChatSessionDto.visitId accepts 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.getVisitSummary adapter (visit-context.adapter.ts:33-35), which delegates to VisitsService.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

  1. Session create (chat): ddx-web → POST /agents/sessions { visitId, patientId, agentType }SessionService.createSession builds contextMetadata with visitId + optionally fhirEncounterId + linkedPatient → repository inserts ChatSession with visitId (:364), linkedEntityType (:324), linkedEntityId (:333).
  2. Session create (voice / FlowAgent): ddx-web → POST /flowagent/sessionsFlowAgentSessionService.create (ddx-api/src/ai/flowagent/services/session.service.ts) also creates a linked ChatSession (TUCAN dispatch route) + publishes SSE on SSEChannels.session(...) — that linkage is what lets the voice session reach back into the TUCAN tool set later.
  3. 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.
  4. First turn: SessionContextBuilder.build runs (context/session.context.ts:131-147) → if session.visitId is present, queries Prisma.visit.findFirst({ where: { OR: [{id}, {fhirEncounterId}] } }) and injects identity.visit (:147).
  5. Tool call (e.g. get_visit_summary): TUCAN routes to VisitContextAdapter.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).
  6. 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.
  7. Frontend pickup: ddx-web (Medical Cards — see medical-cards.md) reads getVisitAgentContext via 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-307contextMetadata build (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-333LinkedEntityType cascade (VISIT > INTAKE > APPOINTMENT > DOCUMENT).
  • ddx-api/src/ai/tucan/session/session.service.ts:341-364 — repository.create persisting visitId, linkedEntityType, linkedEntityId, system prompt.
  • ddx-api/src/ai/tucan/context/session.context.ts:70 — Prisma select including visitId: true.
  • ddx-api/src/ai/tucan/context/session.context.ts:131-147 — per-turn visit lookup OR: [{id}, {fhirEncounterId}]identity.visit.
  • ddx-api/src/ai/tucan/context/adapters/visit-context.adapter.ts:33-35getVisitSummary adapter delegates to getVisitAgentContext.
  • ddx-api/src/clinical/visits/visits.service.ts:464-499getVisitAgentContext impl (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

  • visitId accepts 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 share visitId. 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-668 cover 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, not recordings == null.
  • @deprecated notice: passing FHIR Encounter ID directly is allowed for legacy callers — UUID is the recommended primary identifier (visits.service.ts:460-466).
  • PHI boundary: linkedPatient.uuid is the user UUID, linkedPatient.fhirId is 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.