TUCAN Context Builder — Patient and Session Context Injection
Audiences: developer, internal
Every TUCAN turn starts with
ContextBuilderService.buildContext(...)which assembles a single typedTucanContextfrom 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 rawsession.metadataor 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
TucanContextsnapshot. - Cached aggressively —
ContextCacheServicededuplicates 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.tsdeclares every shape the consumer can read. - The only allowed surface — tools and orchestrator layers MUST
read from
context.*, NOT fromsession.metadatadirectly 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 totucan-context.types.ts. The new field shows up in every tool'sctxautomatically. - Internal (ops / support): The God-object problem is real and
tracked.
context-builder.service.tsis 700+ lines with 26 injected deps. Decomposition is a known refactor target — see Operational Notes.
Architecture
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:
| Slice | Source adapter | Notes |
|---|---|---|
identity: TucanIdentitySnapshot | identity.context.ts | { userId, orgSlug, role }. orgSlug is slug, never UUID. |
org: TucanOrgSnapshot | org.context.ts | slug authoritative; id retained for cross-system joins. |
locale: { language, timezone, ... } | locale.context.ts | dto-priority — session locale can be stale ([[feedback_voice_state_machine_design]] / TUCAN AI memory). |
llmConfig: LLMConfig | llm-config.context.ts | model slug + temperature + maxTokens after tenant overrides applied. |
session: TucanSessionSnapshot | session.context.ts | session row + metadata slices + entity-link tuple. |
patient: TucanPatientSnapshot | patient.context.ts + patient-context.adapter.ts | demographics + FHIR Patient ID + linked-entity bool. |
visit, scheduling, fhir, … | per-domain adapters | every adapter returns a typed sub-snapshot. |
rawUserPrompt | dispatcher sets after buildContext returns | raw 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
| Component | Choice | Why |
|---|---|---|
| Single projection | TucanContext (typed, no any) | One contract surface for all consumers. |
| Adapters in parallel | await Promise.all([...]) inside the builder | Reduces per-turn latency from N× sequential RPCs to 1× longest. |
| Adapter pattern | one class per domain (29 files) | Independently testable, replaceable per tenant. |
| Per-turn cache | ContextCacheService (in-memory, request-scoped) | Avoid double-fetching the same FHIR resource. |
| Org slug, not UUID | TucanIdentitySnapshot.orgSlug: string | SSE contract channel rules — SSEChannels.org() is keyed by slug; passing a UUID typechecks but drops events (PROJECT_LEARNINGS baf23c7b8). |
| Locale priority | dto.locale → session.metadata.locale → 'en' | Voice channels override per-turn; session locale can be stale. |
| LLM config resolution | LLMConfigFactory.buildDudoxxModel(context.llmConfig) at call time | Per-tenant overrides actually flow; explicit model on Agent() is banned ([[feedback_agent_model_inheritance]]). |
| Patient resolution | patient-extractor.ts derives FHIR Patient ID from session metadata, FK columns, or fallback | Multi-source so patient context survives partial DB state. |
| Compaction | compaction.service.ts trims when over budget | Long 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:
- Dispatcher calls
ContextBuilderService.buildContext({sessionId, userId, organizationId, clinicId, locale, ...}, requestId)(ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:165).- Builder resolves identity, org, locale, LLM config.
- Builder loads the session row + extracts the linked patient via
patient-extractor.ts.- 29 adapters run in parallel.
patient-context.adapter.tshits the patient service;fhir-context.adapter.tshits the FHIR client for the bundled patient resource;medications-context.adapter.tslists active medications;allergies-context.adapter.tslists allergies;diagnostic-reports-context.adapter.tsfetches the recent labs.- Cache service ensures the FHIR Patient is fetched once even though three adapters need it.
- Builder merges into one
TucanContextand returns to the dispatcher.- Dispatcher calls Mastra agent; the agent's
execute(input, ctx)readsctx.medications,ctx.allergies,ctx.diagnosticReports— no extra service calls needed.- LLM produces the answer; no tool calls required.
Implicated Code
ddx-api/src/ai/tucan/context/context-builder.service.ts:116— classContextBuilderService.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:162—async 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-buildrawUserPromptassignment.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.ts—TucanIdentitySnapshotresolution (slug authoritative).ddx-api/src/ai/tucan/context/org.context.ts—TucanOrgSnapshotresolution.ddx-api/src/ai/tucan/context/patient.context.ts—TucanPatientSnapshottypes 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— classPatientContextAdapter.ddx-api/src/ai/tucan/context/adapters/visit-context.adapter.ts:15— classVisitContextAdapter.ddx-api/src/ai/tucan/context/adapters/fhir-context.adapter.ts:40— classFhirContextAdapter.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.tsis 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.orgSlugmust be slug, never UUID. Project learningbaf23c7b8(SSE contract channel rules — slug-keyed org channel). Passing a UUID typechecks but silently drops SSE events. EveryTucanIdentitySnapshotmust 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.localedirectly. ContextCacheServiceis 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.tstries (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 fromctx.<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.prismaand is a long-lived denormalized cache for patient hydration. The in-memory cache service is per-turn; the Prisma cache is per-patient. rawUserPromptis set by the caller, not the builder. The builder returns a context without the raw prompt; the dispatcher assigns it after the builder returns.agentsslice is filled post-build.context.agentsis assigned inbuildContext()AFTER full context is assembled — circular-dep dance. Don't readcontext.agentsinside an adapter.
Related Topics
- [[tucan-assistant]] — dispatcher entry; calls
buildContextper 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_AIshard — adapter list and context-builder pitfalls history.