# Uitspraak-engine en AI

De uitspraakbeoordeling is overgegaan van "Azure doet alles" naar "de eigen
engine is primair, Azure is vangnet". Deze pagina documenteert wat er feitelijk
gebeurt: de engine zelf (`engine_service/main.py`), de client
(`lokale_engine.py`), de beslislogica (`routers/oefenen.py` +
`uitspraak_feedback.py`, gedeeld door het woord-oefenen en het rollenspel), de
scores, en de externe diensten (Azure en Mistral).

## Overzicht: wie doet wat

| Dienst               | Gebruik             | Rol                              |
| -------------------- | ------------------- | -------------------------------- |
| OpenPronounce v2     | uitspraakbeoordeling | primair (mits taal ondersteund)  |
| Azure Speech         | uitspraakbeoordeling | vangnet + stille steekproef (opt-in per leerling) |
| Piper (lokaal)       | TTS (voorlezen)      | primair                          |
| Azure Speech         | TTS (voorlezen)      | vangnet (talen zonder lokale stem) |
| Azure Content Safety | beeldmoderatie       | primair                          |
| Mistral              | foto→woorden/dialoog | primair                          |
| fastText (lokaal)    | taaldetectie         | primair                          |
| Mistral              | sorteren woordparen  | primair                          |
| Mistral              | batchvertalen naar NL | primair                         |
| Mistral              | gesprek (ontwerp + antwoorden/hints) | primair             |

## Wanneer wordt Azure wel/niet aangeroepen voor uitspraak?

De beslislogica zit in `routers/oefenen.py` (`oefen_spreken`):

1. **Meerdere alternatieven** (`bron_alt`/`doel_alt` aanwezig, dus >1
   acceptabele referentie) → de eigen engine scoort tegen **elk** alternatief
   en kiest de beste (`_beoordeel_lokaal_beste`); Azure komt hier niet aan te pas.
2. **Eén referentie** én `BEOORDELING_LOKAAL_EERST` aan (standaard) → de eigen
   engine beoordeelt. Slaagt dat, dan is dat het oordeel.
3. **Eigen engine faalt** (timeout `LOKAAL_TIMEOUT_S`, fout, of
   niet-ondersteunde taal) → **Azure beoordeelt** als vangnet. `LOKAAL_TIMEOUT_S`
   is standaard 10 s; de productie-`.env` zet 45 s. Dat is de bovengrens voor de
   hele lokale beoordeling en bepaalt dus hoe lang een verzoek op de engine mag
   wachten voordat Azure het overneemt. Dit vangnet geldt alleen als de leerling
   Azure zelf heeft aangezet (`fallback` of `steekproef`); op de standaardstand
   `niets` blijft de opname op de eigen server.
4. **Steekproef** (`AZURE_STEEKPROEF_OP`, standaard 10 = 1 op de 10) én
   `leerling.azure_modus = "steekproef"` → Azure draait mee naast de
   eigen engine voor kwaliteitscontrole. Dit staat **standaard uit** en gebeurt
   alleen na een **expliciete keuze** van de leerling; zonder die keuze blijft de
   opname volledig bij de eigen engine.

**Uitzondering — drukte is geen storing:** als de eigen engine bereikbaar maar
tijdelijk vol is (HTTP 503/`Retry-After`), herkanst de client
(`UITSPRAAK_OVERBELAST_POGINGEN`, standaard 3) en geeft de webapp daarna een
tijdelijke 503 terug. Er wordt dan **niet** automatisch naar Azure teruggevallen:
congestie mag geen geld kosten en geen kinderstemmen naar een subprocessor
sturen. Azure blijft alleen het vangnet bij een echte fout of een
niet-ondersteunde taal.

**Uitgezette uitspraakserver (beheer):** de beheerder kan de uitspraakserver
bij een storing of zonder internet op de engine-host in één klik uitzetten
(`AppInstelling.uitspraak_uit` via `GET/POST /api/admin/uitspraak`; een DB-rij,
niet `.env` — de gelezen stand wordt 5 seconden per proces gecachet in
`uitspraak_status.py`). Staat hij uit, dan slaat de webapp de engine- en
TTS-host op de engine-host **meteen over** — geen wachttijd op timeouts:
`beoordeel_lokaal()` en `transcribeer_lokaal()` keren direct af (`None`),
`lokale_engine_beschikbaar()` is `false`, `warm_op()` doet niets en voorlezen
valt direct terug op het Azure-vangnet (in de offline bundel eerst de online
brug). Bij het spreken geldt: in de engine-only tak (meerdere referenties,
geen Azure) geeft de webapp **503** met de eigen melding "Onze
uitspraakserver staat tijdelijk uit. Probeer het later opnieuw." — niet de
misleidende "beoordeling mislukt"; in de Azure-fallbacktak (één referentie)
gaat het verzoek meteen naar het vangnet, dus wie Azure heeft aangezet krijgt
gewoon een Azure-beoordeling. De site toont intussen bovenaan een korte
melding (publiek `GET /api/status`).

Azure is een **opt-in per leerling**: `azure_modus` staat standaard op `"niets"`
(Azure wordt nooit gebruikt). De leerling kiest zelf in Instellingen via
`POST /api/instellingen/azure-modus` voor `"niets"`, `"fallback"` (vangnet) of
`"steekproef"` (vangnet + stille kwaliteitssteekproef). Azure is pas beschikbaar
nadat het **e-mailadres is geverifieerd** (OAuth-gebruikers gelden als
geverifieerd). Azure is **nooit** beschikbaar voor **docent-accounts**, voor
**accounts onder de 16** (geboortejaar bekend en jonger dan 16) en voor
**gastleerlingen** (via een deellink, zonder account): die hebben geen
Instellingen en staan altijd op `azure_modus = "niets"`. De server dwingt deze
regels af bij het spreken; een clientvlag kan Azure voor hen niet aanzetten.
**Eén uitzondering:** het beheerdersaccount (`ADMIN_EMAIL`) mag in de
uitspraakmodus Azure als **tweede mening** gebruiken wanneer de eigen engine
afkeurt (`azure_goedgekeurd` in het antwoord), en alleen als dat account Azure
zelf heeft aangezet (`azure_modus` ≠ `niets`). Dat is alleen om de engine te
controleren en geldt uitsluitend voor dat ene account.

Samengevat: **Azure wordt voor uitspraak alleen aangeroepen** bij (a) storing/
timeout/ongeldige taal van de eigen engine, (b) een expliciet toegestane
steekproef, of (c) een afkeuring bij het beheerdersaccount. In de gevallen (a)
en (b) moet de leerling Azure zelf hebben aangezet
(`fallback`/`steekproef`, pas na e-mailverificatie) en geldt dit nooit voor
docenten, accounts onder de 16 of gasten — behalve dus de beheerder in geval (c).
De voorleesstem (TTS) draait lokaal
(Piper); Azure TTS is alleen een vangnet voor talen zonder lokale stem en staat
los van deze keuze.

Wanneer Azure de beoordeling deed (vangnet), ziet de leerling een banner
"Reserve-engine: deze opname is door Azure beoordeeld".

## De eigen engine (OpenPronounce v2) — wat hij meet

De engine draait als een aparte service (FastAPI, `/uitspraak`), buiten het
publieke webproces. De backend stuurt er een WAV-naartoe en krijgt een oordeel
terug. Kernidee: **forced alignment** — de opname wordt vergeleken met de
**fonemen van de referentietekst** (G2P via espeak-ng), niet met een vrij
geraden transcriptie.

### Waar de engine draait

De engine draait **niet** op de publieke VPS, maar op een **eigen server met een
Intel Core i9-14900HX in Friesland (Nederland)**. Die server staat volledig in
eigen beheer: hij is niet vanaf het internet bereikbaar en alleen via een
versleutelde privéverbinding (Tailscale) toegankelijk. **Niemand anders dan wij
heeft er toegang toe** en er is geen externe partij bij betrokken. De
spreekopname wordt hier lokaal beoordeeld en daarna verwijderd.

### Audio-input

- WAV, 16 kHz, mono, 16-bit PCM (de browser levert dit formaat aan).
- De backend zet de PCM in het geheugen om naar WAV (`lokale_engine.py`) en
  verstuurt hem als multipart-form (`audio` + `tekst` + `taal`).
- Er wordt niets op schijf bewaard in het webproces; de engine schrijft alleen
  een tijdelijk WAV-bestand dat meteen wordt opgeruimd.

### Preprocessing (in de engine)

1. Audio inladen (`openpronounce.load_audio`).
2. **SNR-schatting** (`_snr_db`): spraak- versus ruisvermogen in dB.
3. **Ruisonderdrukking** (spectral gating via `noisereduce`) — **alleen** als
   de SNR onder `RUIS_DREMPEL_DB` (22 dB) ligt. Bij schone opnames wordt er
   bewust niet gefilterd (de filter kan zachte medeklinkers van kinderopnames
   aantasten). Uit te schakelen via `UITSPRAAK_RUIS_AAN`.
4. **VAD (Silero)** knipt stilte vóór en na de spraak weg (`_trim_stilte`,
   drempel 0.35, 0,25 s rand). Als de trim meer dan de helft zou weggooien,
   wordt de VAD gewantrouwd en de opname in zijn geheel gebruikt.
5. Opnames korter dan 0,2 s worden afgewezen (HTTP 422).

### Drie neurale passes (parallel)

Per opname draaien drie wav2vec2-large-passes gelijktijdig (threads; torch
geeft tijdens inferentie de GIL vrij):

1. **Open transcriptie** (`speech.transcribe`, ASR-CTC) — alleen ter info,
   zonder taalmodel; wordt gebruikt voor de "gehoord"-tekst en om miscues te
   detecteren.
2. **Foneemherkenning** (`phones.recognize_phones`, phone-CTC) — wat er aan
   klanken is gezegd.
3. **Embeddings** (`speech.extract_embeddings`) — akoestische weergave van de
   opname, voor de DTW-vergelijking met de referentie.

### Referentie (TTS + embeddings + f0)

De referentiekant wordt per `(tekst, taal)` één keer gebouwd en **op schijf
gecached**:

- TTS-synthese van de referentietekst (`audio.text2speech`),
- embeddings van die referentie,
- f0-contour (toonhoogte) via `extract_f0` + `interpolate_f0`.

De cache-sleutel is een SHA-1 over (TTS-backend, stem, taal, referentie). In
het proces zit ook een `lru_cache` (max 256) bovenop de schijf-cache. Tijdens
een opname hoeft dus alleen de leerlingaudio nog door de modellen.

### G2P en fonemen

- De referentietekst wordt met **espeak-ng** naar fonemen omgezet
  (`openpronounce.get_language(lang).espeak`).
- De herkende klanken van de leerling worden met `phones.compare_phones` tegen
  de verwachte fonemen van de referentie gelegd (dit is de "forced alignment").
- **Nederlandse kalibratie:** stemloze/stemhebbende paren en `ə/e` worden als
  "nabije klanken" behandeld (halve fout), zodat de engine niet onterecht
  foutmeldingen geeft. `ɛ/ə` telt als halve fout. Contraste als `ɪ/eː`, `ʏ/u`
  en `ɑ/a` blijven bestaan.
- **Duitse kalibratie:** eindklankverscherping, `ç/x` en r-varianten tellen
  als halve fout.
- **Franse kalibratie:** de engine laat het Frans bewust op de
  openpronounce-standaard staan — óók de "nabije" middenklinkerparen `ø↔y`,
  `ø↔e`, `e↔i` en `o↔u` (halve fout) en de standaard-vergevingsdrempel
  `PHONE_PLAUSIBLE_POSTERIOR = 0.05`. Strenger maken (die paren eruit via
  `_LAKSE_FR_PAREN`, drempel naar 0.15) is geprobeerd en weer teruggedraaid: het
  akoestische model hoort juist korte woorden te wisselvallig. Dezelfde "veux"
  kwam op de ene opname als `[v ø]` binnen (score 100) en op de andere als
  `[əɜ]` (score 44) — en die vergeving bestaat juist om zulke modelglitches
  niet aan de leerling te verwijten. De winst moet uit het model komen
  (finetune), niet uit strengere scorerregels.

### De scores

Alle scores zijn 0-100 (afgerond op 1 decimaal, begrensd op [0,100]), behalve
`per`.

#### `score` (de totaalscore; komt terug als `uitspraak_score`)

```text
score = 0.55 × nauwkeurigheid_incl + 0.25 × akoestische_score + 0.20 × volledigheid
```

Let op: de totaalscore gebruikt `nauwkeurigheid_incl` (dus **met** de
onherkenbare woorden meegeteld), niet de vlakke `nauwkeurigheid_score`. Het
manifest van `GET /offline/scoring` noemt voor de mobiele app andere gewichten
(0.45 / 0.35 / 0.20); dat is de lokale score in de app, niet wat de engine zelf
rekent.

Let op: **`vloeiendheid_score` telt niet mee in de totaalscore.** De
akoestische component wordt hieronder uitgelegd; het is geen apart veld dat de
leerling in de oefen-app ziet. De engine geeft de ruwe `akoestische_afstand`
wel terug; `lokale_engine._kaart_antwoord` stuurt die door.

#### `nauwkeurigheid_score`

Hoe dicht de klanken bij de verwachte fonemen zitten:

```text
nauwkeurigheid = 100 × (1 − totaal_edits / totaal_fonemen)
```

- `totaal_edits` = som van de gewogen foneemfouten uit de per-woord-rapporten.
  Nabije klanken (bijv. `v`↔`f`) tellen als **halve** fout (`NEAR_PHONE_COST`
  = 0.5 in openpronounce). Een substitutie wordt bovendien geheel of deels
  kwijtgescholden naarmate het **verwachte** foneem plausibel in de
  CTC-posteriors zat:
  `confidence = kost × (1 − plausibiliteit / PHONE_PLAUSIBLE_POSTERIOR)`, met
  `PHONE_PLAUSIBLE_POSTERIOR` op de openpronounce-standaard **0.05**. De engine
  wijzigt die drempel bewust niet (zie de Franse kalibratie hierboven).
- `totaal_fonemen` = som van de verwachte fonemen.
- Woorden waarvan geen enkel foneem overlapt met het doelwoord (CTC-
  hallucinaties, "mavi" bij "morgen") tellen **niet** mee — een echte
  uitspraakfout deelt vrijwel altijd klanken met het doelwoord.

Daarnaast geeft de engine `nauwkeurigheid_incl` terug: dezelfde breuk, maar dan
méétellend de onherkenbare woorden (zowel hun edits als hun fonemen). De webapp
gebruikt die variant in het getrapte oordeel (`classificeer_uitspraak` in
`uitspraak_feedback.py`).

#### `volledigheid_score`

Hoeveel van de verwachte fonemen er ook echt gezegd is:

```text
volledigheid = 100 × (1 − verwijderd / len(verwachte_fonemen))
```

`verwijderd` = het aantal "delete"-operaties in de Levenshtein-vergelijking
tussen verwachte en gehoorde fonemen. Weggelaten klanken (of een afgebroken
woord) verlagen deze score.

#### Akoestische score (intern, alleen via de totaalscore)

Vergelijkt de embeddings van de leerling met die van de referentie:

```text
akoestisch  = gemiddelde DTW-afstand(leerling-embeddings, referentie-embeddings)
akoestische_score = 100 × (1 − (akoestisch − acoustic_good) / 12)
```

`acoustic_good` is de per-taal gekalibreerde "goede" afstand uit OpenPronounce;
`12` is `AKOESTISCHE_SPAN`. Het meet dus hoe akoestisch dicht de opname bij de
referentie ligt (los van de fonemen).

#### `vloeiendheid_score`

Een grove schatting van het **spreektempo** ten opzichte van de referentie:

```text
verhouding = duur_opname / duur_referentie
afwijking  = |verhouding − 1|
vloeiendheid = max(30, 100 − afwijking × 40)
```

Dus: wie (veel) trager of sneller spreekt dan de referentie scoort lager, met
een ruime tolerantie voor het natuurlijke tempoverschil met een synthetische
stem en een ondergrens van 30. Deze score wordt getoond en gebruikt voor het
"aarzeling"-niveau, maar telt **niet** mee in `score`.

#### `per` (phone error rate)

`vergelijk["phone_error_rate"]` uit OpenPronounce, afgerond op 4 decimalen.
Het is de ruwe foneemfoutratio (0 = perfect; hoger = meer fouten). `per` is
diagnostisch: het wordt opgeslagen maar bepaalt niet de score.

#### `fouten` (met Nederlandse hints)

Elke fout bevat het woord, verwachte klank, gehoorde klank, een
betrouwbaarheid en een **hint in gewoon Nederlands** (`_hint_voor_fout`), nooit
rauw IPA. De klanknamen komen uit `KLANK_NAAM` — inclusief de neusklanken
`ɑ̃`, `ɛ̃`, `ɔ̃`, `œ̃` — met per-taal-uitzonderingen uit `KLANK_NAAM_PER_TAAL`
(Spaans: `θ`, `ð`, `ɣ`, `β`, `r`, `ɾ`; een ð is daar de zachte d, niet de
Engelse 'th'), opgezocht via de helper `_klank_naam(foon, lang)`.
Voorbeelden uit de code:

- verwacht stemhebbend, gehoord stemloos: "Je zei de 'f' in plaats van de 'v'.
  Zet je stem aan bij de 'v'.";
- verwacht stemloos, gehoord stemhebbend: "Je zei de 'v' in plaats van de 'f'.
  Zeg de 'f' zacht, zonder je stem te gebruiken.";
- andere klank: "Je zei de 's' in plaats van de 'z'. Probeer de 'z' duidelijker
  te zeggen."

Is de klank helemaal weggelaten (`soort` `del`/`del_eind`) of schoof een
cluster van méér dan twee afwijkende fonemen in elkaar, dan volgt bewust
géén specifieke klanktip: de positionele uitlijning is dan te onbetrouwbaar.
De foneem-details (`phones`/`soort`) worden vóór de hint-generatie aan elke
fout gehangen, zodat de hint op het `soort`-veld kan letten; fouten zonder
hint komen niet in de respons-lijst `fouten` terecht.

In de backend worden alleen fouten met `confidence ≥ 0.5` getoond, maximaal 3
(`lokale_engine.py`); de laag-vertrouwde CTC-gokken blijven uit de UI.

### Normalisatie en miscues

- De open transcriptie wordt alleen aan de leerling getoond als hij op de
  referentie lijkt (`difflib`-ratio ≥ 0.8); anders worden de woordfouten
  getoond in plaats van een onzin-transcriptie ("two rhymes" bij "to write").
- **Miscue:** als de leerling deels andere woorden zei (overlap ≥ 50% én de
  woordensets verschillen, en het geen zelfcorrectie is) en de nauwkeurigheid
  < 60 is, geeft de engine alléén de uitleg "Ik herkende andere woorden dan
  op het kaartje. Lees het kaartje woord voor woord en zeg precies die
  woorden" mee — de score zelf wordt niet meer opgevijzeld (de vroegere bodem
  van 65 gaf een fout woord een cijfer waarmee het een correct uitgesproken
  doelwoord kon verslaan).
- **Woordidentiteit (webapp):** het goed/fout-oordeel leunt niet meer op dat
  ene engine-signaal. `uitspraak_feedback.woordidentiteit` combineert twee
  onafhankelijke bronnen: per doelwoord de beste gelijkenis met een woord uit
  de open transcriptie (`difflib`, drempel 0.65) en de foneemstatus
  (`onherkenbaar`, vanaf woordlengte 4). Het oordeel `anders` volgt alleen als
  beide dezelfde kant op wijzen, de open transcriptie plausibel is (niet veel
  te kort of veel te lang) en minstens 60% van de doelwoorden vervangen is;
  anders is het `zelfde` of `onduidelijk`. Bij één los woord moet het gehoorde
  woord bovendien 0.7-1.4× zo lang zijn als het doelwoord — de CTC verzint bij
  twijfel korte woorden ("mavi" bij
  "morgen") en dat mag geen beschuldiging worden. `onduidelijk` is een
  volwaardige uitkomst: dan krijgt de leerling "ik kon niet goed horen welk
  woord je zei" in plaats van "je zei een ander woord".
- **Leesbare klankuitleg (webapp):** `uitspraak_feedback.verrijk_fouten` zet
  een foneemfout om in gewone taal: leesbare klanknamen (`klank`), waar je de
  klank maakt en hoe (`uitleg`), en vanaf de tweede poging een oefenzin
  (`oefen`, bijvoorbeeld "Zeg het rustig drie keer achter elkaar: think.").
  Rauw IPA gaat nooit naar de leerling; de frontend valt niet meer terug op
  "je zei de ə, maar het is de ɑ".
- **Exacte herkenning:** is de opgeschoonde transcriptie van de eigen engine —
  vergeleken zonder apostrofs (`'`/`’`), haakjes en spaties (`_uitspraak_sleutel`
  in `routers/oefenen.py`) — gelijk aan de referentie, dan wordt een
  "bijna"-oordeel opgewaardeerd naar "goed". Zo telt een correct uitgesproken
  elisie-woord (`l'`, `d'`, `j'`) mee, ook al laat de engine die tekens zelf weg
  ("l'application" → "lapplication"). Dit signaal staat ook als `exact_herkend`
  in de respons van `POST /api/oefenen/spreken/{id}` en laat het woord in de
  strenge uitspraakmodus (`streng=true`) zonder de score-drempel meetellen op
  de ladder.

### Wat de engine meet (en waarom)

- **Nauwkeurigheid** — zitten de klanken juist? (de kern van uitspraak)
- **Volledigheid** — is het hele woord gezegd, niet afgebroken?
- **Akoestisch (embeddings/DTW)** — klinkt de opname globaal als de referentie?
- **Vloeiendheid** — tempo/aarzeling (niet de score, maar de "aarzeling"-melding).
- **PER / fouten** — welke specifieke klanken fout zijn, vertaald naar hulp.

### Concurrency, timeouts en foutafhandeling (engine)

- Drie neurale passes per opname draaien parallel in een gedeelde
  `ThreadPoolExecutor`; het aantal gelijktijdige opnames per worker wordt
  begrensd door een semafoor (`UITSPRAAK_MAX_GELIJKTIJDIG`, standaard 2). De
  service draait meerdere uvicorn-workers (`UITSPRAAK_WORKERS`, productie 3),
  elk met een eigen semafoor: de totale capaciteit is dus
  `workers × UITSPRAAK_MAX_GELIJKTIJDIG` gelijktijdige opnames.
- Niet-neurale fases (VAD, ruisonderdrukking, DTW, prosodie) zijn per worker
  geserialiseerd met een lock (BLAS-aanroepen zijn niet thread-safe). Die lock
  is in de praktijk de doorvoerlimiet: `UITSPRAAK_MAX_GELIJKTIJDIG` verhogen
  (2→4) verandert de gemeten doorvoer nauwelijks, omdat de niet-neurale fases
  toch één voor één per worker gaan. De echte knoppen zijn ONNX en
  `UITSPRAAK_WORKERS`.
- Wachten is begrensd: een verzoek dat langer dan `UITSPRAAK_WACHT_TIMEOUT_S`
  (standaard 8 s) op capaciteit wacht, krijgt **503 + `Retry-After`** in plaats
  van stil door te draaien tot de client-timeout. `/health` toont daarnaast
  `actief`, `wachtend` en `max_gelijk`.
- Bij het opstarten worden alle modellen **één keer geladen en opgewarmd**, zodat
  het eerste verzoek niet minuten wacht. In **lazy-modus** (`UITSPRAAK_LAZY=true`,
  gebruikt door de offline/desktopbundel) slaat de warm-up dit over: `/gereed` is
  direct groen, verzoeken blokkeren niet op de warm-up en de basis + het
  taalmodel worden pas geladen wanneer ze nodig zijn.
- Fouten: ongeldige taal → 422; te korte/onleesbare opname → 422; overige →
  500. De client (`lokale_engine.py`) vangt echte fouten af en geeft `None`
  terug, waarna de backend op Azure terugvalt — maar alleen als de leerling
  Azure zelf heeft aangezet (`fallback`/`steekproef`). **Overbelasting (429/503)
  is de uitzondering:** die wordt kort herkanst en leidt nooit tot een
  automatische Azure-aanroep. De backend begrenst de hele lokale beoordeling op
  `LOKAAL_TIMEOUT_S` (standaard 10 s; productie `.env` zet 45 s).

### Lazy-modus (`UITSPRAAK_LAZY`) en laadstatus

Standaard laadt de engine bij het opstarten alle modellen vooraf (warm-up).
Met `UITSPRAAK_LAZY=true` — zoals de offline/desktopbundel zet — gebeurt dat
niet: de warm-up laadt niets zwaars, `/gereed` meldt direct gereed en een
verzoek wacht niet op de warm-up. In plaats daarvan laadt de engine de basis
(VAD, embeddings, fonemen) en het CTC-taalmodel voor de gevraagde taal pas bij
het eerste gebruik. Elke laadstap heeft een eigen lock (`_MODEL_SLOTEN` in
`main.py`, `_LAAD_SLOTEN` in `onnx_engine.py`), zodat gelijktijdige verzoeken
niet twee modelkopieën in het RAM trekken. Zonder de variabele verandert het
productiegedrag niet.

In de offline/desktopbundel is de codering van de processen daarnaast vastgezet
op UTF-8: `windows_app/offline_config.py` zet `PYTHONIOENCODING=utf-8` in zowel
de web- als de engine-omgeving, `windows_app/launcher.py` reconfigureert
stdout/stderr naar UTF-8 met `errors="backslashreplace"` en de C#-host
(`zegt_desktop_host/LokaleStack.cs`) zet dezelfde variabele én leest de
kinduitvoer als UTF-8. Zonder die instellingen kiest Python op een Windows-pipe
cp1252; fonetische IPA-tekens (ɑ, ə) in de diagnostische beoordelingslog gaven
dan een `UnicodeEncodeError`, waardoor het spreekverzoek met HTTP 500 kon
mislukken. De beoordelingslog in `routers/oefenen.py` gaat via `_veilig_print`,
dat bij een `UnicodeEncodeError` terugvalt op een ASCII-variant van de regel.

Twee endpoints ondersteunen dit (naast `/health` en `/gereed`):

- `POST /voorbereiden` (form `taal`) — start het laden van de basis + het
  taalmodel en antwoordt direct **202** met `{"bezig": true, "taal": …}`.
  Idempotent: is het model al gereed of al onderweg, dan volgt opnieuw 202
  zonder tweede laadthread. Onbekende taal of ontbrekend model → 422.
- `GET /status` — `{"lazy": bool, "basis": …, "talen": {code: …}}`. `basis` is
  `"bezig"`, `"gereed"` of `"fout"`; elk van de zeven talen (`nl`, `en`, `de`,
  `fr`, `es`, `it`, `pt`) heeft de waarde `"ontbreekt"`, `"bezig"`, `"gereed"`
  of `"fout"`, waarbij `"bezig"` ook "nog niet geladen" betekent.

### Online engine-brug (offline bundel)

De webapp van de offline/desktopbundel start de engine lui (zie hierboven).
Zolang een taalmodel nog niet geladen is, blijft spreken mogelijk via de
**online productie-engine**: de desktop-app geeft zijn inlogsessie aan de lokale
webapp, die daarmee `POST {UITSPRAAK_BRUG_URL}/api/engine/uitspraak` aanroept
(`lokale_engine.beoordeel_lokaal(..., sta_brug_toe=True)`, gestuurd via het
formveld `lokaal_gereed`). De brug is stateless (geen database-write) en vereist
gewoon een geldige login; de sessie staat alleen in het lokale proces. Zodra het
lokale model klaar is, meldt de client `lokaal_gereed=true` en gaat de
beoordeling weer volledig lokaal.

- De brug staat alleen aan als `UITSPRAAK_BRUG_URL` gezet is én er een sessie is
  doorgegeven (`POST /api/offline/engine/brug/sessie`). Online is de variabele
  leeg en verandert er niets; alleen de offline bundel zet hem (standaard
  `https://zeg-t.nl`, override via `OFFLINE_BRUG_URL`).
- De frontend leest die status via `GET /api/offline/engine/status` (`online_brug`)
  en toont dan "Spraak beschikbaar — het lokale model wordt op de achtergrond
  geladen".
- Voorlezen: in de offline bundel is er geen lokale TTS-dienst
  (`UITSPRAAK_TTS_URL` leeg); `lokale_tts.spreek_uit` probeert dan
  `POST /api/engine/tts`, anders Azure.

### Mobiel-offline endpoints (`/offline/*`)

Voor de native Android-app levert de engine kant-en-klare referenties, vocabulaire en
scoringsconstanten, zodat de app dezelfde scoring kan nabootsen als de webapp. De
endpoints zijn afgeleid van de bestaande pijplijn en gebruiken dezelfde cache; er is
geen tweede implementatie. (`engine_service/main.py`, sectie "Mobiel-offline".)

- `GET /offline/referentie/bestand` (query `tekst`, `taal` = tweelettercode,
  `soort` = `wav`|`emb`|`f0`) — één referentiebestand. `wav` geeft de
  16 kHz-referentie-TTS (`audio.text2speech`, dezelfde cache als productie);
  `emb` en `f0` de float16 little-endian-export van de referentie-embeddings en
  de toonhoogtecontour. Antwoord met `Content-Length`, `ETag` (sha256) en
  `X-Referentie-Backend` (`v5-onnx-phoneemb`/`v4-onnx`/`v3` + TTS-backend + stem).
- `GET /offline/fonemen` (query `tekst`, `taal`) — de verwachte fonemen;
  `per_woord` is de genormaliseerde foneemreeks per woord die de scoring
  vergelijkt, `espeak` de ruwe espeak-uitvoer ter controle.
- `GET /offline/vocab` — token-id → IPA van het foneemmodel (392 tokens) met
  `blank_id` (het CTC-pad-token), `special_tokens` en `sha256` over de tabel.
- `GET /offline/vad` — het Silero-VAD ONNX-model met `Content-Length`, `ETag` en
  `Accept-Ranges`. De engine zelf draait de **torch-JIT-variant** van Silero
  (`load_silero_vad`); dit endpoint levert het ONNX-bestand uit het
  `silero-vad`-pakket, met dezelfde gewichten, zodat de app exact dezelfde
  stiltetrim kan doen. Zit het ONNX-bestand niet in het pakket, dan antwoordt de
  engine 404.
- `GET /offline/scoring` — alle scoringsconstanten uit de draaiende code
  (manifestversie 2): `versie` (2), `scoring_passes`
  (`["phone.ctc", "phone.embeddings"]`), `engine_backend` (de engine draait de
  scoring op de gecombineerde `phone_emb`-graaf: op de engine-host staat
  `UITSPRAAK_COMBINED` aan; de code-default blijft uit en valt terug op de
  losse `phone`- en `emb_en`-grafen), `gewichten` (0.45/0.35/0.20), `herweging` (formule
  `(0.45 * nauwkeurigheid + 0.20 * volledigheid) / (0.45 + 0.20)`, gewichten
  0.6923/0.3077), `akoestische_span` (12.0), `acoustic_ankers` (indicatieve
  DTW-ankers per taal voor het gecombineerde `phone_emb`-model én het oude
  `emb_en`), per taal `acoustic_good`/`akoestische_span`/`espeak`/`asr_model`/
  `near_phones`/`phone_map`/`referentie_backend`, `foneemmodel` (token-aliassen,
  alternate pronunciations, drempels), `miscue`, `hints` en `voorverwerking`
  (sample rate, golfnormalisatie, VAD- en ruisdrempels), plus `constanten_hash`
  over alles behalve de hash zelf. De webapp proxyt dit als
  `GET /api/offline/scoring`.

### ONNX Runtime (INT8) — snellere passes

De drie wav2vec2-forwards zijn geëxporteerd naar ONNX Runtime met dynamische
INT8-quantisatie (`engine_service/onnx/`, modellen op de engine-host onder
`/opt/uitspraak-engine/onnx/`). Gemeten op de LXC met hetzelfde draadbudget als
de engine (2 draden per pass):

| Pass                | torch fp32 | ONNX INT8 | Winst |
| ------------------- | ---------- | --------- | ----- |
| Open transcriptie   | 0,92 s     | 0,42 s    | 2,2x  |
| Foneemherkenning    | 0,97 s     | 0,41 s    | 2,4x  |
| Embeddings          | 0,91 s     | 0,43 s    | 2,1x  |

End-to-end op dezelfde opname: ~2,15 s (torch) → ~0,63 s (ONNX), met ~1 punt
verschil in de akoestische deelscore (35% weging → ~0,4 op het eindcijfer).

- Aanzetten/uitschakelen met `UITSPRAAK_ONNX` (`true`/`false`, standaard uit in
  de code, aan in de productie-unit). Terugschakelen is veilig: torch en ONNX
  hebben een **eigen referentiecache-sleutel** (`zegt-ref-v3` vs
  `zegt-ref-v4-onnx`), dus leerling-ONNX vergelijkt nooit met referentie-torch.
- Ontbrekende taal-ONNX-modellen vallen automatisch terug op torch.

Voor de mobiele app is daarnaast een **gecombineerd** `phone_emb`-model
geëxporteerd (`engine_service/onnx/export_phone_emb.py`, upload via
`upload_phone_emb.py`): dezelfde encoder-forward levert zowel de CTC-logits
(`logits`, 392 tokens) als de encoder-embeddings (`embeddings`, `[T, 1024]`).
Het toestel downloadt zo één ~357 MB-model en draait één pass in plaats van de
losse `phone` + `emb_en` (~714 MB). De engine schakelt over op dit model met
`UITSPRAAK_COMBINED` (standaard uit in de code, **aan** in de productie-unit op
de engine-host); bij een ontbrekend bestand vallen de losse
grafen automatisch terug. Het gecombineerde model heeft zijn eigen
referentiecache-tag `zegt-ref-v5-onnx-phoneemb`, zodat referenties van torch
(`v3`), losse ONNX (`v4`) en gecombineerd ONNX (`v5`) nooit door elkaar lopen.

## Azure — wat er nog over is

- **Vangnet bij uitspraak** (`azure_speech.py:beoordeel_uitspraak`) — zie de
  beslislogica hierboven. Alleen opt-in per leerling en nooit voor
  docent-accounts, accounts onder de 16 of gastleerlingen.
- **TTS** (`spreek_uit`) — de voorleesfunctie (`POST /api/oefenen/tts`).
- **Content Safety** — moderatie van thumbnails en AI-foto's
  (`azure_content_safety.py`): blokkeert bij categorieën Hate/SelfHarm/Sexual/
  Violence met severity ≥ `CS_THRESHOLD` (standaard 4).

### Hoe Azure scoort

Azure Pronunciation Assessment gebruikt het 5-punts gradingsysteem; de scores
worden daarom met 20 vermenigvuldigd naar 0-100:

```text
uitspraak      = PronScore × 20
nauwkeurigheid = AccuracyScore × 20
vloeiendheid   = FluencyScore × 20
volledigheid   = CompletenessScore × 20
```

De **goed/fout-beslissing** bij Azure (`_beoordeel_enkel`):

```text
is_correct = (tekst_match EN uitspraak ≥ 60) OF (uitspraak ≥ 80 EN nauwkeurigheid ≥ 70)
```

- `tekst_match` = de herkende tekst is gelijk aan de referentie.
- Het "sterk uitgesproken"-vangnet (≥ 80 én ≥ 70) tolereert een klein
  tekstverschil (accent e.d.), maar blijft streng: een fout of gemist woord
  haalt het niet.
- Herhaling (de leerling zei de referentie meermaals, bijv. "où est, où est"):
  geldt als correct, met `uitspraak = max(uitspraak, nauwkeurigheid, 70)`.
- Ontbrekende woorden (`_bepaal_ontbrekend`) leveren een specifieke melding op
  ("Ik hoorde het begin, maar miste het laatste woordje").

### Goed/fout-drempels: lokaal vs Azure

| Bron                | Correct wanneer                                             |
| ------------------- | ----------------------------------------------------------- |
| Eigen engine        | getrapt oordeel `classificeer_uitspraak` (`uitspraak_feedback.py`): `is_correct` = `goed` of `bijna` |
| Azure               | tekstmatch én ≥ 60, óf ≥ 80 én nauwkeurigheid ≥ 70          |

De eigen engine beslist dus niet op een vlakke `uitspraak_score`-drempel:
`goed` bij nauwkeurigheid ≥ 80 én volledigheid ≥ 90 zonder zware klankfouten,
`bijna` vanaf nauwkeurigheid ≥ 50 (of `nauwkeurigheid_incl`, of bij alleen
nabije afwijkingen vanaf 40). Alleen bij een oudere engine zonder woorddetail
is de terugval `uitspraak_score ≥ LOKAAL_CORRECT_DREMPEL` (standaard 55) →
`bijna`.

De ladder gebruikt daarna opnieuw de drempels 55 en 80 op `uitspraak_score`
(zie `/llm/herhaalsysteem.md`).

## Meerdere alternatieven (`bron_alt` / `doel_alt`)

- Een woord kan meerdere acceptabele antwoorden hebben, gescheiden door `|`
  (bijv. `doel_woord="the dog"`, `doel_alt="the hound"`).
- De eigen engine vergelijkt tegen **één** referentietekst. Zonder te weten
  wélk alternatief de leerling zei, kan er niet eerlijk vergeleken worden.
- Daarom scoort de eigen engine bij meerdere alternatieven tegen **elk**
  alternatief en kiest de beste score (`_beoordeel_lokaal_beste`). Azure komt
  hier niet aan te pas.
- Ook een optioneel deel tussen haakjes levert meerdere referenties op
  (`vouw_haakjes_varianten` in `models.py`, toegepast in `_spreek_referenties`):
  `l'appli(cation)` wordt de volledige vorm `l'application` én de korte vorm
  `l'appli`. Beide gelden als goed; de haakjes zelf gaan nooit naar de engine.
- Voor de leerling verandert er niets: elk alternatief is gewoon goed.

## Mistral (`mistral_client.py`) en lokale taaldetectie

- `screenshot_naar_woorden` — één foto → letterlijke woordparen
  (`pixtral-12b-2409`, temperature 0, `response_format: json_object`, strenge
  OCR-systemprompt `_KOP_OCR_SYSTEEM`: alleen letterlijk transcriberen,
  onduidelijk weglaten, nooit vertalen/aanvullen). Geen rotatie-parameter meer.
- `foto_naar_dialoog` — één foto met een dialoog (bijv. uit een lesmethode) →
  letterlijke regels met rol `leerling`/`tegenstander` (`{"regels":[...]}`);
  geen duidelijke dialoog → lege lijst.
- `vertaal_zinnen` — letterlijke batchvertaling naar het Nederlands
  (`mistral-small-latest`), exact hetzelfde aantal zinnen in dezelfde volgorde;
  al-Nederlandse zinnen blijven onveranderd; ValueError bij een onverwacht
  antwoord (→ 502 in de router).
- `screenshot_naar_tekst` — leest één screenshot en geeft alleen de leesbare
  tekst terug (`pixtral-12b-2409`); gebruikt bij het ontwerpen van een gesprek.
- `sorteer_woorden` — bepaalt per paar of het omgedraaid staat (chunks van 40,
  één herkansing per chunk; `mistral-small-latest`).
- `detecteer_taal` — draait lokaal via fastText (`lokale_ai.py`, `lid.176`),
  niet via Mistral.
- **Gespreksmodus** (`routers/gesprek.py`): `gesprek_ontwerp` is een meerronde
  docent-chat die een systeemprompt ontwerpt (`finaliseer` legt hem vast);
  `gesprek_antwoord` genereert een platte antwoordtekst in de doeltaal op basis
  van de chatgeschiedenis; `gesprek_hint` geeft één korte hint in de brontaal.
  Deze drie draaien op `GESPREK_MODEL` (`mistral_client.py`, standaard
  `mistral-large-latest`), niet op `mistral-small-latest`.
- Aangeroepen vanuit `routers/ai.py` (rate limit 20–60/u per route) en
  `routers/gesprek.py` (eigen limieten per route).
- Voor deze aanroepen staat bij Mistral **Zero Data Retention** aan en is de
  **trainingsuitsluiting** actief: de invoer en uitvoer worden niet bewaard
  langer dan nodig en niet voor modeltraining gebruikt.

### Vrije spraakherkenning (`/transcriptie`)

Voor de gespreksmodus heeft de engine een licht `POST /transcriptie`-endpoint
(`engine_service/main.py`): VAD/ruisonderdrukking plus alleen de open
transcriptie-pass, zonder scoring. `lokale_engine.transcribeer_lokaal` stuurt de
opname hierheen en gebruikt het `transcriptie`-veld. Bestaat het endpoint nog
niet (oudere engine, HTTP 404/405), dan valt de client terug op `/uitspraak` met
een referentie en gebruikt alleen de open transcriptie daaruit.
