Ga naar inhoud

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:

  1. NEN 7510 / AVG art. 30: elk AI-event moet traceerbaar zijn (wie, wanneer, welk model, welk doel).
  2. PII-bescherming bij externe verwerking: directe identifiers mogen niet ongereduceerd naar een externe verwerker.
  3. 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/*.py en 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.py voorkomt regressie.

Hoe een nieuwe scope toevoegen

  1. Maak prompt-template features/ai/prompts/templates/<scope>_v1.txt.
  2. Voeg de scope toe aan features/ai/config.py:MODELS met de juiste tier.
  3. Voeg eventueel een rate-limit toe in features/ai/rate_limit.py.
  4. Roep de service aan vanuit de feature-code met een AICallContext.
  5. Werk de lockfile bij zodat CI de template-hash kan verifieren.

Hoe een nieuwe provider toevoegen

  1. Implementeer een adapter in features/ai/providers/<provider>.py conform de Protocol-interface.
  2. Map provider-specifieke excepties in providers/base.py:_map_exception.
  3. Werk factory.py bij zodat de adapter op basis van de provider-config wordt gekozen.
  4. 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.