06-ai-tucanwave: W3filled9 citations

TUCAN Context Builder — Patient and Session Context Injection

Audiences: developer, internal

TUCAN Context Builder — Patient and Session Context Injection

Every TUCAN turn starts with ContextBuilderService.buildContext(...) which assembles a single typed TucanContext from 29 per-domain adapters (patient / visit / FHIR / scheduling / medications / vitals / allergies / …). Tools, the planner, the reporter, and the orchestrator all read from this one projection — never from raw session.metadata or ad-hoc service calls.

Business Purpose

A clinical conversation is only useful if the AI knows who the patient is, who the doctor is, what the current visit is about, what the recent labs say, which drugs the patient is on, which allergies to watch out for. Without a context layer, every tool would need its own data-fetching code, every cache invalidation would be ad-hoc, and the system would refetch the same FHIR resources dozens of times per turn.

The context builder solves this with a single canonical projection:

  • One assembly per turn — 29 adapters run, often in parallel, to produce one TucanContext snapshot.
  • Cached aggressively — ContextCacheService deduplicates inside a turn so adapters that share data (patient + visit + FHIR) only hit the underlying services once.
  • Typed end-to-end — no any; tucan-context.types.ts declares every shape the consumer can read.
  • The only allowed surface — tools and orchestrator layers MUST read from context.*, NOT from session.metadata directly or bare service calls. (This is the project's "patient data aggregation" boundary.)

This boundary is what lets Dudoxx ship a 350-tool catalog without each tool re-implementing patient hydration. It is also what makes multi-tenancy actually work: org slug, FHIR partition, clinic ID all enter the context once, then propagate to every adapter and every downstream consumer.

Audiences

  • Investor: Patient context aggregation is the unsexy backbone of the AI moat. Doubling the tool count adds zero context-fetch cost because all tools share one projection.
  • Clinical buyer (doctor / nurse): When TUCAN says "I see Mrs. Garcia has metformin and a sulfa allergy," it is reading from the context bundle, not asking the LLM to "remember" — so it is deterministic and audit-replayable.
  • Developer / partner: Adding patient state ("patient is enrolled in cardiology program X") = add an adapter under context/adapters/ + register it in the builder + add a typed slice to tucan-context.types.ts. The new field shows up in every tool's ctx automatically.
  • Internal (ops / support): The God-object problem is real and tracked. context-builder.service.ts is 700+ lines with 26 injected deps. Decomposition is a known refactor target — see Operational Notes.

Architecture

diagram
        Per-request input (DTO + session lookup)
                          │
                          ▼
┌────────────────────────────────────────────────────────────────┐
│  ContextBuilderService.buildContext(input, requestId)          │
│   1. resolveIdentity(input) → TucanIdentitySnapshot             │
│   2. resolveOrg + Locale + LLMConfig                            │
│   3. resolvePatient + Visit (entity links)                      │
│   4. run 29 adapters in parallel (cache-coalesced):             │
│      patient-context     visit-context                          │
│      fhir-context        scheduling-context                     │
│      document-context    clinical-notes-context                 │
│      conditions-context  vitals-context                         │
│      allergies-context*  medications-context                    │
│      immunizations-context  diagnostic-reports-context          │
│      assessment-context    intake-context                       │
│      drug-interaction-context                                   │
│      insurance-coverages-context                                │
│      diagnosis-engine-context                                   │
│      communication-context  messenger-context                   │
│      event-bus-context    info-container-context                │
│      session-context     tasks-context                          │
│      todo-context        agent-delegation-context               │
│      service-requests-context    prescriptions-context          │
│      onboarding-context   rag-context                           │
│   5. merge into a single TucanContext                           │
│   6. return                                                      │
└────────────────────────────────────────────────────────────────┘
                          │
                          ▼
┌────────────────────────────────────────────────────────────────┐
│  Consumers (READ ONLY from TucanContext)                        │
│   · DispatcherStage / DispatchEngine                            │
│   · L1 IntentPlannerAgent / L2 MemoryResolver / L3 ToolFilter   │
│   · L4 RenderOrchestrator / L5 PersistencyAnchor                │
│   · Every Mastra tool's execute(input, ctx) receives ctx.*       │
└────────────────────────────────────────────────────────────────┘

TucanContext — the typed projection

tucan-context.types.ts is the contract surface. Notable slices:

SliceSource adapterNotes
identity: TucanIdentitySnapshotidentity.context.ts{ userId, orgSlug, role }. orgSlug is slug, never UUID.
org: TucanOrgSnapshotorg.context.tsslug authoritative; id retained for cross-system joins.
locale: { language, timezone, ... }locale.context.tsdto-priority — session locale can be stale ([[feedback_voice_state_machine_design]] / TUCAN AI memory).
llmConfig: LLMConfigllm-config.context.tsmodel slug + temperature + maxTokens after tenant overrides applied.
session: TucanSessionSnapshotsession.context.tssession row + metadata slices + entity-link tuple.
patient: TucanPatientSnapshotpatient.context.ts + patient-context.adapter.tsdemographics + FHIR Patient ID + linked-entity bool.
visit, scheduling, fhir, …per-domain adaptersevery adapter returns a typed sub-snapshot.
rawUserPromptdispatcher sets after buildContext returnsraw user input before rewriter.

The whole projection is a single object passed by reference. Tools receive ctx.services (NestJS DI bridge) + ctx.session + ctx.patient + ctx.org + … No tool is allowed to instantiate its own service or hit raw Prisma.

Per-domain adapters (29 today)

Every adapter is @Injectable() and has a single resolve(input, shared) style method. They are independent, side-effect-free projections — they can run in parallel inside the builder.

The full list (verified via fd --type f . ddx-api/src/ai/tucan/context/adapters | wc -l → 29):

agent-delegation-context     assessment-context
clinical-notes-context       communication-context
conditions-context           diagnosis-engine-context
diagnostic-reports-context   document-context
drug-interaction-context     event-bus-context
fhir-context                 immunizations-context
info-container-context       insurance-coverages-context
intake-context               medications-context
messenger-context            onboarding-context
patient-context              prescriptions-context
rag-context                  scheduling-context
service-requests-context     session-context
tasks-context                todo-context
visit-context                vitals-context
allergies-context (index export)

Plus supporting context primitives (identity, locale, org, patient, session, llm-config) under context/*.context.ts.

ContextCacheService + CompactionService

  • context-cache.service.ts — deduplicates adapter fetches within a turn (e.g. patient FHIR Patient ID lookup hit twice = one network call).
  • compaction.service.ts — applied when assembled context exceeds a token budget; trims oldest non-critical slices.

Tech Stack & Choices

ComponentChoiceWhy
Single projectionTucanContext (typed, no any)One contract surface for all consumers.
Adapters in parallelawait Promise.all([...]) inside the builderReduces per-turn latency from N× sequential RPCs to 1× longest.
Adapter patternone class per domain (29 files)Independently testable, replaceable per tenant.
Per-turn cacheContextCacheService (in-memory, request-scoped)Avoid double-fetching the same FHIR resource.
Org slug, not UUIDTucanIdentitySnapshot.orgSlug: stringSSE contract channel rules — SSEChannels.org() is keyed by slug; passing a UUID typechecks but drops events (PROJECT_LEARNINGS baf23c7b8).
Locale prioritydto.locale → session.metadata.locale → 'en'Voice channels override per-turn; session locale can be stale.
LLM config resolutionLLMConfigFactory.buildDudoxxModel(context.llmConfig) at call timePer-tenant overrides actually flow; explicit model on Agent() is banned ([[feedback_agent_model_inheritance]]).
Patient resolutionpatient-extractor.ts derives FHIR Patient ID from session metadata, FK columns, or fallbackMulti-source so patient context survives partial DB state.
Compactioncompaction.service.ts trims when over budgetLong sessions don't blow the token window.

Alternatives rejected:

  • Per-tool context fetch — what we had pre-rewrite; 350 tools refetching FHIR Patient 350 times. Tested and burned.
  • One giant SQL JOIN — couples context to one DB; doesn't work with FHIR + Prisma + MinIO + Redis hybrid storage.
  • Lazy context (per-tool getter pattern) — defeats per-turn caching; can't run in parallel.

Data Flow

Business outcome: A doctor asks "what's Mrs. Garcia's last A1c and is she allergic to anything I should worry about?" TUCAN answers in one turn with no extra round trips because the patient context + recent labs + allergies are already in the context bundle when the LLM is called.

Technical mechanism:

  1. Dispatcher calls ContextBuilderService.buildContext({sessionId, userId, organizationId, clinicId, locale, ...}, requestId) (ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:165).
  2. Builder resolves identity, org, locale, LLM config.
  3. Builder loads the session row + extracts the linked patient via patient-extractor.ts.
  4. 29 adapters run in parallel. patient-context.adapter.ts hits the patient service; fhir-context.adapter.ts hits the FHIR client for the bundled patient resource; medications-context.adapter.ts lists active medications; allergies-context.adapter.ts lists allergies; diagnostic-reports-context.adapter.ts fetches the recent labs.
  5. Cache service ensures the FHIR Patient is fetched once even though three adapters need it.
  6. Builder merges into one TucanContext and returns to the dispatcher.
  7. Dispatcher calls Mastra agent; the agent's execute(input, ctx) reads ctx.medications, ctx.allergies, ctx.diagnosticReports — no extra service calls needed.
  8. LLM produces the answer; no tool calls required.

Implicated Code

  • ddx-api/src/ai/tucan/context/context-builder.service.ts:116 — class ContextBuilderService.
  • ddx-api/src/ai/tucan/context/context-builder.service.ts:119 — constructor with 26 injected dependencies (God-object scope).
  • ddx-api/src/ai/tucan/context/context-builder.service.ts:162async buildContext(input, requestId) — the single per-turn assembly entry.
  • ddx-api/src/ai/tucan/context/context-builder.service.ts:247 — reference to the dispatcher's post-build rawUserPrompt assignment.
  • ddx-api/src/ai/tucan/context/tucan-context.types.ts:1 — types-only contract; no service imports, no DI tokens.
  • ddx-api/src/ai/tucan/context/identity.context.tsTucanIdentitySnapshot resolution (slug authoritative).
  • ddx-api/src/ai/tucan/context/org.context.tsTucanOrgSnapshot resolution.
  • ddx-api/src/ai/tucan/context/patient.context.tsTucanPatientSnapshot types and helpers.
  • ddx-api/src/ai/tucan/context/session.context.ts — session snapshot incl. metadata slice typed accessors.
  • ddx-api/src/ai/tucan/context/locale.context.ts — locale resolution (dto → session metadata → 'en').
  • ddx-api/src/ai/tucan/context/llm-config.context.ts — LLM config snapshot (tenant override merge).
  • ddx-api/src/ai/tucan/context/patient-extractor.ts — patient resolution from session/FK/metadata.
  • ddx-api/src/ai/tucan/context/context-cache.service.ts — per-turn request-scoped cache.
  • ddx-api/src/ai/tucan/context/compaction.service.ts — token-budget trimming.
  • ddx-api/src/ai/tucan/context/adapters/patient-context.adapter.ts:29 — class PatientContextAdapter.
  • ddx-api/src/ai/tucan/context/adapters/visit-context.adapter.ts:15 — class VisitContextAdapter.
  • ddx-api/src/ai/tucan/context/adapters/fhir-context.adapter.ts:40 — class FhirContextAdapter.
  • ddx-api/src/ai/tucan/context/adapters/medications-context.adapter.ts — active medications.
  • ddx-api/src/ai/tucan/context/adapters/allergies-context.adapter.ts — allergies list.
  • ddx-api/src/ai/tucan/context/adapters/vitals-context.adapter.ts — vitals snapshot.
  • ddx-api/src/ai/tucan/context/adapters/diagnostic-reports-context.adapter.ts — recent labs + reports.
  • ddx-api/src/ai/tucan/context/adapters/intake-context.adapter.ts — open intake forms.
  • ddx-api/src/ai/tucan/context/adapters/drug-interaction-context.adapter.ts — interaction lookup cache.
  • ddx-api/src/ai/tucan/context/adapters/insurance-coverages-context.adapter.ts — insurance.
  • ddx-api/src/ai/tucan/context/adapters/event-bus-context.adapter.ts — SSE channel info for the publisher path.
  • ddx-api/src/ai/tucan/context/adapters/info-container-context.adapter.ts — info-container sidebar state.

Operational Notes

  • context-builder.service.ts is a known God-object — 700+ LOC, 26 injected deps. Decomposition is on the refactor list. Adding a 30th adapter without splitting the file should trigger a review.
  • orgSlug must be slug, never UUID. Project learning baf23c7b8 (SSE contract channel rules — slug-keyed org channel). Passing a UUID typechecks but silently drops SSE events. Every TucanIdentitySnapshot must use the clinic slug.
  • Adapters MUST be side-effect-free. Adapters are projections; the only allowed write is through L5 persistence after the dispatcher returns. An adapter that writes during context build breaks idempotency.
  • Locale priority: dto → session → 'en'. Voice channels can override per-turn; session locale can be stale. Don't read session.metadata.locale directly.
  • ContextCacheService is request-scoped. Don't cache across requests inside the builder — the next turn may have different identity / tenant / patient.
  • Patient resolution is multi-source. patient-extractor.ts tries (1) session FK column, (2) metadata.linkedPatient, (3) recently-seen patient in turn history. If you change the extractor, update the corresponding adapter assumptions.
  • No raw service calls in tools. Tools must consume ctx.services.* for any side-effect-bearing call or read from ctx.<slice> for projections. Bypassing into raw services breaks the test surface.
  • Tools that need a slice that does not exist — add an adapter, not an inline service call. The adapter pattern is cheap (one file, one class, one method).
  • PatientContextCache (Prisma model) lives in ddx_api_ai/schema.prisma and is a long-lived denormalized cache for patient hydration. The in-memory cache service is per-turn; the Prisma cache is per-patient.
  • rawUserPrompt is set by the caller, not the builder. The builder returns a context without the raw prompt; the dispatcher assigns it after the builder returns.
  • agents slice is filled post-build. context.agents is assigned in buildContext() AFTER full context is assembled — circular-dep dance. Don't read context.agents inside an adapter.
  • [[tucan-assistant]] — dispatcher entry; calls buildContext per turn.
  • [[tucan-dispatcher]] — consumer of context for stage routing.
  • [[ai-medical-tools]] — every tool's execute(input, ctx) reads from this projection.
  • [[ai-chat-sessions]] — session metadata that several adapters read from.
  • [[agent-templates]] — Jinja2 template variables resolve from context namespaces.
  • [[medical-cards]] (W2) — patient demographics + clinical card data that adapters bundle into the context.
  • [[clinical-visits]] (W2) — VisitContextAdapter reads from this surface.
  • docs/claude-context/CLAUDE_TUCAN_API_INJECTION.md — tool-context bridge pattern.
  • MEMORY_TUCAN_AI shard — adapter list and context-builder pitfalls history.
    TUCAN Context Builder — Patient and Session Context Injection — Dudoxx Docs | Dudoxx