ADR-0007: AI service-laag
Datum: 2026-04-25 Herzien: 2026-05-18 (migratie naar AWS) Status: Geaccepteerd
Context
Alle managed AI-verwerking voor patientdata loopt op AWS: het LLM via Amazon Bedrock (Claude Sonnet 4.6 voor Atlas Pro, Claude Haiku 4.5 voor Atlas Fast), RAG via Amazon Bedrock Knowledge Bases met een Aurora Serverless v2 pgvector-cluster, en spraak-naar-tekst via AWS Transcribe. De tekst-naar-spraak van de werkafspraken-podcast blijft bewust op Google Cloud (Cloud Text-to-Speech, Chirp 3 HD), omdat die geen patientdata verwerkt en de stem merkbaar beter is.
Vóór de centralisatie verspreidden AI-aanroepen zich door de codebase, elk met een eigen prompt, retry-logica, foutafhandeling en audit-aanpak. Drie compliance-eisen dwingen consolidatie af:
- NEN 7510 / AVG art. 30: elk AI-event moet traceerbaar zijn (wie, wanneer, welk model, welk doel).
- PII-bescherming bij externe verwerking: directe identifiers mogen niet ongereduceerd naar een externe verwerker.
- EU-residency: alle AI-calls moeten technisch binnen de EU plaatsvinden, met een code-validatie die bij startup faalt.
Verspreide ad-hoc implementatie schaalt niet. Een nieuwe call-site vergeet snel de audit-laag, een PII-patroon wordt niet uniform toegepast, model-keuzes driften. Daarbij coexisteren nu meerdere providers (Bedrock voor LLM en vision, AWS Transcribe voor STT, Google Cloud voor de podcast-TTS), wat een uniforme grens nog belangrijker maakt.
Beslissing
Een centrale AI service-laag in backend/features/ai/ met dunne provider-adapters en alle policy in de services. Geen module buiten features/ai/ importeert een provider-SDK rechtstreeks (Bedrock via boto3 of de Anthropic-Bedrock-binding, amazon-transcribe, google.cloud.texttospeech, google.cloud.speech). De grens wordt afgedwongen via een architectuur-test (features/ai/tests/test_boundary_imports.py).
De RAG-retrieval voor Atlas is hierop één bewuste uitzondering: die leeft in features/atlas/bedrock.py en roept de Bedrock Knowledge Base rechtstreeks aan via de gedeelde client-wrapper in common/aws_clients.py (cross-account STS). De reden staat in ADR-0008. Er is dus geen services/rag.py.
Architectuur
features/ai/
config.py EU-region fail-fast (validate_eu_residency,
validate_data_residency), scope -> model-pinning
classification.py DataClassification (GENERAL, SENSITIVE_CLINICAL,
PATIENT_AUDIO)
context.py AICallContext (correlation_id, tenant, scope,
classification)
exceptions.py AIError-hierarchie (transient/permanent split)
interfaces.py Protocol-definities voor LLM/STT/TTS-adapters
audit/log.py log_ai_call context manager naar /remedice/audit/ai
prompts/
registry.py Templates + sha256-lockfile-validatie bij startup
lockfile.json sha256 per template; CI verifieert
templates/ <name>_v<N>.txt
redaction/
regex.py BSN (11-proef), telefoon NL, email, postcode, datum
pipeline.py uitsluitend regex (geen LLM-pass, bewust)
enforcement.py reject_if_pii voor input-paden
validation/
refusal.py refusal-marker detectie
schemas.py gestructureerde-output validatie
retry.py tenacity-policies per error-categorie
circuit_breaker.py Redis-backed per provider
rate_limit.py Redis token-bucket per (tenant, scope)
providers/
base.py map_provider_errors decorator
bedrock_llm.py Claude via de AnthropicBedrock-binding
bedrock_vision.py Haiku classify+caption, Sonnet voor stroomschema's
aws_transcribe.py nl-NL streaming via de amazon-transcribe SDK
gcp_stt.py STT-rollback (AI_STT_PROVIDER="gcp")
gcp_tts.py Chirp 3 HD (Charon), de bewuste TTS-provider
services/
llm.py rate-limit + PII-validate + template + audit + refusal
transcription.py rate-limit + STT + regex-redact output + audit
speech.py rate-limit + PII-validate input + TTS + audit
factory.py lazy singletons + test-overrides + provider-branching
Provider-keuze loopt via Python-constants in config/settings/base.py: AI_LLM_PROVIDER (bedrock), AI_STT_PROVIDER (aws, met gcp als rollback-escape-hatch) en AI_TTS_PROVIDER (gcp). factory.get_speech_service() weigert aws expliciet: er is bewust geen Polly-adapter, want de TTS blijft op Chirp.
config.py:MODELS mapt scope naar tier (atlas_answer en kompas_extract op Pro, de overige op Fast). De inference-profile-IDs zelf staan als BEDROCK_MODEL_PRO/BEDROCK_MODEL_FAST in config/settings, consistent met features/atlas/bedrock.py en bedrock_vision.py. validate_eu_residency() is provider-bewust: bij Bedrock en AWS Transcribe valideert het AWS_REGION tegen EU_AWS_REGIONS, bij de GCP-paden de GCP-location. Deze validatie draait fail-fast bij Django-startup.
Audit-event shape
Alle AI-calls emitten een event naar /remedice/audit/ai met event_type=ai.<scope>. De metadata bevat provider, region, model, prompt-template-versie, prompt-hash (sha256), response-hash, input- en output-tokens, duur, data-classificatie, scope, correlation-id, user-id, tenant-id, status (success, error, refused of rate_limited), error-type, en redaction-categorieen plus -count. Er staat geen prompt- of response-content in de audit-log, alleen sha256-hashes voor reproduceerbaarheid.
Alternatieven overwogen
- Per-feature AI-helper: geen centrale plek voor audit, redaction en rate-limiting. Verworpen omdat het niet schaalt en audits laat falen.
- Aparte AI-microservice: netwerk-overhead, deployment-complexiteit en een extra failure-mode zonder toegevoegde waarde op onze schaal.
- Rechtstreeks SDK met audit-decorator per call: een decorator laat de policy alsnog opt-in. Het service-laag-pattern dwingt het af.
Gevolgen
- Een nieuwe AI-call-site is een nieuwe scope-key plus prompt-template plus model-mapping. De service-laag handelt audit, redaction en rate-limiting af.
- Een provider-wissel is een adapter-implementatie plus een factory-branch. Services en call-sites blijven onveranderd. De multi-provider-realiteit (Bedrock, Transcribe, Chirp) is hierdoor een config-keuze, geen herschrijving.
- Auditors lezen
services/*.pyen zien alle policy expliciet. De adapters blijven dun. - De PII-pipeline is uitsluitend regex. Een LLM-redactiepas is bewust verwijderd, omdat die de PII-tekst eerst naar een model zou sturen. In de patientdata-paden is de subverwerker-grens (Bedrock EU, AWS DPA, geen modeltraining) de waarborg, niet de redactie. Zie de DPIA.
- Boundary-enforcement via
features/ai/tests/test_boundary_imports.pyvoorkomt regressie.
Hoe een nieuwe scope toevoegen
- Maak prompt-template
features/ai/prompts/templates/<scope>_v1.txt. - Voeg de scope toe aan
features/ai/config.py:MODELSmet de juiste tier. - Voeg eventueel een rate-limit toe in
features/ai/rate_limit.py. - Roep de service aan vanuit de feature-code met een
AICallContext. - Werk de lockfile bij zodat CI de template-hash kan verifieren.
Hoe een nieuwe provider toevoegen
- Implementeer een adapter in
features/ai/providers/<provider>.pyconform de Protocol-interface. - Map provider-specifieke excepties in
providers/base.py:_map_exception. - Werk
factory.pybij zodat de adapter op basis van de provider-config wordt gekozen. - Werk de allow-list van de boundary-test bij als de provider-SDK een nieuwe import is.
Een prompt wijzigen
Prompts staan als <name>_v<N>.txt in prompts/templates/. De registry serveert per naam de hoogste versie. Bij een wijziging wordt de template aangepast en de sha256 in lockfile.json ververst. CI verifieert de hashes en Django faalt bij startup op een mismatch, zodat een ongewijzigde lockfile of een onbedoelde promptwijziging niet ongemerkt in productie komt.