Skip to main content

Tenant Overlay System

The voice channel runs in a multi-tenant SaaS. Each hospital tenant has a different name, different campus addresses, different doctors, and (sometimes) different STT mishears specific to its directory. The challenge: serve all of these correctly without hardcoding any of them in shared code or in YAML.

This page documents the tenant overlay architecture introduced May 4, 2026 (commit 20ec058).

Updated 2026-05-13

Two material updates since the original architecture:

  • FAQ purge (ADR-0055). All 10 ZOL-specific hand-curated FAQ entries were removed on 2026-05-12 (commit a9820c3f). The surviving FAQ surface in zol.yaml now contains only safety/policy entries (crisis, emergency dispatch) plus the overnacht_ambiguity clarification. Patient-facing factual answers come from the RAG corpus, not from the YAML.
  • Admin UI (Sprint E Wave D). Hospital admins can now CRUD their overlay from /admin/voice-overlay/<slug> — routing-rule edit modal, taxonomy inline-edit + add-new row, YAML import/export tab, empty-tenant onboarding banner. No engineer-only YAML required for routine changes. Backend surface: GET / POST / PUT / DELETE /api/v1/admin/voice-overlay/{slug} plus POST /api/v1/admin/voice-overlay/{slug}/import and GET .../export. Implementation commits: e3297cd0 (read API, Wave B) → 0e82ba5d (viewer UI, Wave C) → 1130f103 (write API + import/export, Wave D backend) → 9b6ae389 / 324f7bfa / 5214b88f (D3/D4/D5 frontend) → 2ba8956a (empty-tenant banner).

The problem

Before the overlay system, two locations contained tenant-specific data:

  1. _FAQ_ENTRIES in voice_faq_tool.py had a hardcoded address_general answer with a specific street address ("Schiepse Bos 6 in Genk"). When the hospital relocated, the FAQ silently served stale content forever.
  2. _STT_NORMALIZATIONS in intent_classification_service.py mixed generic Dutch phonetic mishears (decoren → doctoren) with tenant-specific corrections (zon → zol for the ZOL hospital name).

Both patterns failed multi-tenant correctness: shipping hospital #2 would mean either editing shared code (bad) or shipping their data with hospital #1's still in place (worse).

The architecture

Three categories of voice config, each with a different home:

CategoryWhere it livesWhy
Generic patterns + language rulesSource code (shared _FAQ_ENTRIES, _STT_NORMALIZATIONS)Hospital-agnostic; benefits any tenant
Tenant-specific phonetic-recovery (STT mishears)YAML overlay (tenant_overlays/_yaml/<slug>.yaml)Phonetic data that doesn't exist anywhere else
Tenant data (addresses, names, hours)DB + registered Python rendererSingle source of truth; no duplication

At request time the three are composed:

shared registry (generic, language-level) ← source code
+ tenant overlay (STT mishears) ← _yaml/<slug>.yaml
+ DB-driven renderers (campus listing, ...) ← reads via get_taxonomy(slug)
─────────────────────────────────────────────────
effective voice config for this turn

Components

tenant_overlays/ package

  • schema.py — Pydantic models for the YAML structure. Top-level fields: tenant, faq_entries, stt_normalizations. Each FAQ entry has per-language pattern + answer maps. extra="forbid" means typos (e.g., faq_entreis) fail loud at boot.
  • loader.py — Reads one YAML file, validates against the schema, compiles regex patterns to re.Pattern instances, and returns an immutable TenantOverlay dataclass. Any error (bad YAML, invalid regex, unknown language code, mismatched filename↔tenant) raises InvalidTenantOverlay at boot. Bad config never reaches a request.
  • registry.py — Eager-loads every *.yaml under _yaml/ at import time. get_overlay(slug) returns the matching overlay or EMPTY_OVERLAY for unknown tenants (graceful fallback, not an error). reload_overlays() re-scans the directory for dev hot-reload.
  • _yaml/<slug>.yaml — One file per tenant. Carries STT mishears today; can hold tenant-specific FAQ overrides in the future.

voice_faq_renderers.py — DB-driven answer composition

The shared _FAQ_ENTRIES declares an intent and its match patterns in code. For entries whose answer depends on tenant DB data, the entry sets answer_renderer="<name>" instead of a static answers map:

FAQEntry(
key="address_all_campuses",
patterns={
"nl": [re.compile(r"\b(alle\s+campussen|...)\b", re.IGNORECASE)],
"en": [re.compile(r"\ball\s+campuses\b", re.IGNORECASE)],
# ...
},
answer_renderer="list_campuses", # NEW field
)

At match time, match_faq looks up the renderer in RENDERERS and invokes it with (language, tenant_slug). The render_campus_listing renderer reads get_taxonomy(slug).hospital_campuses (DB-cached, in-memory after startup) and formats per-language prose. House numbers and postal codes stay as digits per the TTS rule.

Add a campus to the DB → next request reflects it. Zero sync gap.

YAML schema (one file per tenant)

# tenant_overlays/_yaml/zol.yaml
tenant: zol

stt_normalizations:
# Hospital name mishears (STT homophone)
zon: zol
sol: zol
# Clinician surnames
geerte: geert
jurissen: jeurissen
jurissens: jeurissen

# faq_entries: [] # tenant-specific FAQ overrides go here when needed

Wire-up across the voice path

The tenant_slug is resolved once per WS connection (from Settings.public_tenant_slug) and threaded through:

Inside rag_service, the slug is already resolved upstream via DB lookup and stored in s["hospital_config_key"]_classify_intent_and_rewrite reads it from there and passes to _normalize_stt_mistakes, no extra plumbing through query_stream needed.

Onboarding a new hospital tenant

  1. Create app/services/voice/tenant_overlays/_yaml/<new-slug>.yaml with their tenant slug as tenant: and any STT phonetic mishears specific to their hospital name + clinician surnames.
  2. Set PUBLIC_TENANT_SLUG=<new-slug> in the deployment env.
  3. Populate the app.hospitals row, app.campuses rows, app.departments rows in the database (the hospital-agnostic phase 5 work from April 10 already enables this — no code changes).
  4. Restart the backend.

The same shared _FAQ_ENTRIES (campus listing, visiting hours, parking, phone numbers, etc.) and the same renderer code serves the new tenant automatically.

Failure modes — the design wants these to be loud

FailureWhere it surfaces
Malformed regex in a YAML patternInvalidTenantOverlay at process boot — backend crashes refusing to start
Unknown language code (typo)Same — boot-time crash with file path + entry key
YAML filename doesn't match tenant: fieldSame — silent renames are forbidden
Renderer returns None (no DB data, unknown lang)FAQ matcher falls through to next entry — eventually to RAG
Tenant slug not in registryEMPTY_OVERLAY returned; shared registry still applies (graceful)

The asymmetry is deliberate: boot-time errors are non-recoverable (operator must fix the YAML), runtime fall-throughs are recoverable (RAG handles unknown queries fine).

What's NOT in the YAML

By policy:

  • Campus addresses, phone numbers, opening hours — DB, accessed via renderers.
  • Doctor names, department canonical names — DB (hospital taxonomy).
  • Hospital website URL, branded full name — PromptContext, populated from app.hospitals row.

Anything that already lives in the database stays there.

Reference

  • Implementation commit: 20ec058 — feat(voice): tenant-aware overlay system + DB-driven FAQ renderers
  • Schema validator: app/services/voice/tenant_overlays/schema.py
  • Loader: app/services/voice/tenant_overlays/loader.py
  • Registry + public API: app/services/voice/tenant_overlays/__init__.py
  • Renderers: app/services/voice/voice_faq_renderers.py
  • Tests: tests/unit/services/voice/test_tenant_overlay_*.py, test_voice_faq_renderers.py