# API, auth en rate limits

Basis: `/api/…` (FastAPI). OpenAPI: `/docs` en `/openapi.json` zijn publiek.

## Auth

- Sessiecookie `zegt_sessie` (holds `gebruiker_id` en `rol`), `max_age=SESSION_MAX_AGE`
  (standaard 30 dagen, schuivend), `SameSite=lax`, `https_only` behalve lokaal.
- Elke route haalt de gebruiker uit de sessie en verifieert tegen de database
  (`auth_utils.leerling_uit_sessie`); routes die alleen voor docenten zijn,
  lopen door `vereis_docent` (rol `docent`).
- Wachtwoorden: scrypt (`n=16384, r=8, p=1`), zout+hash in
  `auth_utils.hash_wachtwoord` / `verifieer_wachtwoord`.
- Publiek (geen login vereist): `/api/auth/*`, `/api/demo/*`,
  `/api/gesprek/status`, `/api/status`,
  `/api/snelheid/status`, `/api/snelheid/stress` (+ `/{run_id}`), `/docs`,
  `/openapi.json`.

## CSRF

- Iedere **POST/PUT/DELETE** vereist de header `X-CSRF-Token`; anders 403.
- Uitzondering: de stateless prefixes `/api/demo/` en `/api/engine/`
  (`CSRF_VRIJE_PREFIXES` in `main.py`). De engine-brug wordt server-naar-server
  aangeroepen met de meegegeven inlogsessie en zonder sessie-CSRF-token; de
  login-eis blijft daar wel staan.
- De token haal je uit de sessie via `GET /api/auth/csrf` (`{token}`).
- Geïmplementeerd als eigen middleware in `main.py` (staat binnen de
  SessionMiddleware). `GET`/`HEAD`/`OPTIONS` zijn vrijgesteld.

## Rate limits

- Sliding-window limiter (`rate_limit.py`): met `REDIS_URL` een gedeelde teller
  (correct over meerdere webworkers), zonder een in-memory venster per proces
  (gereset bij een restart, alleen correct met één webworker).
- `vereis_limiet(sleutel, max, venster_seconden)` → 429 bij overschrijding.
- `RATE_LIMIT_VRIJ_EMAILS` in config stelt specifieke e-mailadressen vrij.
- Per route zijn er limieten, bijv. spreken 60/min, rollenspel-spraak 60/min,
  registreren 5/u, inloggen 10/5 min, AI-foto 20/u, AI-vertalen/-taaldetectie/
  -sorteren 60/u, TTS 120/min, sync 30/min, engine-brug (uitspraak én TTS)
  120/min, snelheidstest 5/10 min per IP.

## Route-overzicht

Zie ook de routertabel in `/llm/architectuur.md`.

### Auth (`/api/auth`)
`registreren`, `inloggen`, `uitloggen`, `csrf`, `me`,
`verificatie-versturen`, `verifieer/{token}`, `wachtwoord-vergeten`,
`reset-wachtwoord`, `oauth/google`, `oauth/github` (plus callbacks) en
`oauth/microsoft` (nog niet actief — antwoordt met `not_available`).

Het beheerdersaccount is het account met `ADMIN_EMAIL` **en een geverifieerd
e-mailadres**; een niet-geverifieerd account met dat adres krijgt geen
beheerrechten. Koppelt een OAuth-login aan een bestaand wachtwoordaccount dat
zijn e-mailadres nog niet heeft geverifieerd, dan weigert de server de login
(zo kan niemand eerst met andermans adres registreren).

- **Sessieversie:** elke sessie bevat `sessie_versie`; bij een wachtwoordreset,
  wachtwoordwijziging of e-mailwijziging gaat de teller op `Leerling.sessie_versie`
  omhoog en worden alle andere sessies geweigerd (401).
- **Registratie:** het antwoord is bewust identiek voor een nieuw en een al
  bestaand e-mailadres (`{"status":"ok","bericht":...}`, geen account-id). Een
  nieuwe registratie zet wél een sessie; de SPA controleert daarna via
  `/api/auth/me` of er echt een account is.
- **Per-IP-limieten:** naast de limiet per e-mailadres zijn er NAT-vriendelijke
  limieten per IP op registreren (60/u), inloggen (120/5 min), verificatiemail
  en wachtwoord-reset (60/u).

### Oefenen (`/api/oefenen`)
- `GET /volgende` — het volgende woord (query: `lijst_id`, `modus`, `sinds`,
  `ronde`, `praktijk`, `toch`). Met `toch=true` negeert de server de wachttijd:
  is niets aan de beurt, dan wordt de hele set beschikbaar gesteld (zelfde
  willekeurige keuzelogica, open sessie-herhalingen gaan vóór) en komt er altijd
  een gewoon woord terug — nooit "wachten" of (bij flitskaarten) "klaar". In de
  `modus=doorgaan`-tak levert `toch=true` na het uitputten van de ronde wél weer
  een woord in plaats van het pauzescherm. Zonder de parameter is het gedrag
  onveranderd. `modus` kan onder meer `slim`, `uitspraak`, `spreken`, `typen`,
  `meerkeuze` of `flitskaarten` zijn; `modus=typen` kiest de zelfstandige
  typmodus, ook voor nieuwe en niet-moeilijke woorden. In `modus=uitspraak`
  geeft de server altijd `modus="spreken"` terug (`_bouw_volgende`), dus nooit de
  spellingscheck "spellen" — ook niet bij moeilijke of al beheerste woorden. Bij
  `modus=doorgaan` geeft `praktijk` de gekozen vorm mee, bijvoorbeeld
  `praktijk=typen`, zodat die vorm behouden blijft.
- `POST /spreken/{id}` — audio (UploadFile) beoordelen; formvelden
  `azure_toegestaan` (standaard `false`), `lokaal_gereed` (standaard `true`) en
  `streng` (standaard `false`). Met `lokaal_gereed=false` mag de server in de
  offline bundel de online engine-brug gebruiken zolang het lokale model nog
  laadt. Met `streng=true` (Uitspraak oefenen) telt de poging alleen als goed op
  de herhaalladder als `oordeel != "oefenen"` (dus "goed" óf "bijna"),
  `aarzeling_niveau != "sterk"` én `uitspraak_score >= 75`, óf als het woord
  **exact herkend** is (`exact_herkend` in de respons: de opgeschoonde open
  transcriptie is precies het doelwoord, `_uitspraak_sleutel` in
  `routers/oefenen.py`); anders blijft het woord op zijn plek
  (`verwerk_spreek_resultaat` met `is_correct=false`). De drempel staat bewust
  op 75 (eerder 85): de foneem-CTC is op korte, vooral Franse woorden zo streng
  dat een correcte uitspraak de 85 zelden haalt. Het `is_correct` in de
  respons én in de opgeslagen `Oefenpoging` weerspiegelt dat strenge oordeel;
  de client gebruikt exact dezelfde regel (`_strengGoed` in `js/student.js`).
  De respons bevat naast `oordeel`/`reden` ook `woord_identiteit`
  (`{oordeel: "zelfde"|"anders"|"onduidelijk", zekerheid, gehoord, signalen,
  woorden}`): het bewijs achter de vraag of de leerling het juiste wóórd zei.
  `reden = "onduidelijk"` betekent dat de engine het zelf niet zeker weet; dan
  volgt een neutrale "zeg het nog eens rustig"-tip in plaats van een
  beschuldiging. Elke fout in `fouten` kan `klank` (leesbare klanknamen),
  `uitleg` (waar zit de klank, hoe maak je hem) en — vanaf de tweede poging op
  hetzelfde woord — `oefen` (oefenzin) bevatten.
- `POST /spellen/{id}` — JSON-body `{antwoord, praktijk?}`. Met
  `praktijk: "typen"` accepteert de route typen in de zelfstandige Typen-modus,
  ook voor nieuwe woorden en woorden die niet als moeilijk zijn gemarkeerd.
  Zonder die waarde blijft de bestaande slimme typvraag beperkt tot moeilijke
  woorden nadat flitskaarten en meerkeuze zijn afgerond, of de toepasselijke
  spellingscheck bovenaan de ladder. Typen gebruikt dezelfde invoer als die
  typvragen en registreert een normale SRS-poging; als het woord beheerst is of
  op de hoogste trede staat zonder open herhalingen, behoudt de spellingscheck
  ook in deze modus haar aparte afhandeling.
- `POST /meerkeuze/{id}`, `POST /flitskaarten/{id}`.
- `POST /tts` — tekst → spraak (Piper lokaal; Azure als vangnet voor talen zonder
  lokale stem; max. 400 tekens; rate limit 120/min). De tekst wordt vóór synthese
  opgeschoond (`normaliseer_voor_tts`, in `lokale_tts.py` én de TTS-dienst):
  em-/en-dash en gespatieerde `--` worden een komma, markdown en emoji
  verdwijnen. Antwoord is `audio/wav` met
  een `ETag` (sha256 van de wav) en
  `Cache-Control: private, max-age=31536000, immutable`; een `If-None-Match` met
  dezelfde ETag geeft **304** zonder de audio opnieuw te sturen.
- `GET /geschiedenis?limit=N` — recente pogingen zonder flitskaarten. `N` is
  standaard 20 en wordt begrensd op 1–100. De oefeninterface toont daaruit de
  15 nieuwste items.
- `GET /uitspraak-server` — of de eigen uitspraakserver beschikbaar is voor de
  oefenpagina (login vereist): `{"beschikbaar": bool, "uit": bool}`. Staat de
  beheerdersschakelaar "uitspraakserver uit" aan (zie Status hieronder), dan
  antwoordt hij `{"beschikbaar": false, "uit": true}` zonder de engine aan te
  vragen.

### Woordenlijsten (`/api/woordenlijsten`)
- CRUD op sets, `/{id}/woorden`, `POST /woorden`, `DELETE /woorden/{id}`,
  `POST /importeren`, `/{id}/reset-voortgang`, `/{id}/deellink`,
  `GET /{id}/deellinks`, `DELETE /{id}/deellink/{link_id}`,
  `POST /upload-thumbnail`.
- Deellinks kunnen aan een vaste oefenmodus hangen. `POST /{id}/deellink` met
  body `{modus}` maakt een nieuwe link; toegestane waarden zijn `"slim"`,
  `"uitspraak"`, `"spreken"`, `"meerkeuze"`, `"typen"` en `"flitskaarten"`
  (`"slim"` en leeg worden als `None` opgeslagen). `GET /{id}/deellinks` geeft
  de actieve links, elk met `id`, `token`, `url`, `is_actief`, `aantal_gebruikt`
  en `modus`; `DELETE /{id}/deellink/{link_id}` zet één link uit. Het oudere
  `GET /{id}/deellink` (enkelvoud) geeft de nieuwste actieve link of maakt er
  één aan. `DeelLinkAntwoord` bevat `id` en `modus`; `DeelInfoAntwoord` bevat
  `modus`.
- Een set heeft een `soort`: `"woordjes"` (standaard) of `"rollenspel"`.
  `POST /api/woordenlijsten` accepteert `soort` (andere waarden vallen terug op
  `"woordjes"`); `PUT /api/woordenlijsten/{id}` accepteert `soort` én per woord
  `rol` (`"leerling"` of `"tegenstander"`, anders `"leerling"`). `soort` staat in
  `WoordenlijstAntwoord` en `DeelInfoAntwoord`; `rol` staat per woord in
  `WoordAntwoord` en `SetWoordAntwoord`. Klonen neemt zowel `soort` als `rol` over.
- Elk item van `GET /api/woordenlijsten` (ook `?mijn=true`) bevat
  `inhouds_hash`: een stabiele 32-tekens sha256 over positie + woordvelden
  (`positie`, `bron_woord`, `doel_woord`, `bron_alt`, `doel_alt`, `moeilijk`).
  Database-id's en `rol` doen niet mee, zodat een her-import niet als wijziging
  telt.
- `GET /{id}/download` levert een zip waarvan `set.json` onder `set` naast
  `aantal_geoefend` ook `inhouds_hash` bevat. Woorden toevoegen
  (`POST /woorden`) en verwijderen (`DELETE /woorden/{id}`) zetten
  `Woordenlijst.bijgewerkt_op`, zodat de offline-app een inhoudswijziging ziet.
- `GET /{id}/offline-info` — grootte-indicatie van de offline-download, zelfde
  auth/toegang als `/download` (rate limit `offline-info:{leerling_id}`).
  Antwoord: `{lijst_id, slug, doel_taal, aantal_woorden, audio_grootte_bytes,
  audio_grootte_geschat, model_bytes_nu, model_bytes_per_variant,
  referentie_bytes, volledig_bytes}`. De audiogrootte is altijd geschat
  (`true`): de TTS-caches zitten alleen in RAM (webapp + Piper-dienst), dus per
  woord wordt een conservatieve schatting op basis van tekstlengte gemaakt
  (22050 Hz mono 16-bit PCM ≈ 44,1 kB/s, ~12 tekens/s + wav-kop).
  `model_bytes_nu` en `model_bytes_per_variant` komen uit het mobiel-manifest
  (het gecombineerde `phone_emb` + VAD, per variant); `referentie_bytes` schat de
  referentiebestanden (gTTS-wav 16 kHz, float16-embeddings `[T,1024]`, float16-f0
  en de ipa-JSON, ~50 frames/s); `volledig_bytes` = `model_bytes_nu +
  referentie_bytes`. Zonder manifest blijven de modelvelden 0.
- `GET /by-slug/{slug}` levert naast de setmetadata (incl. `soort`) ook `woorden`
  mee (max. 200 items in setvolgorde: `{id, bron_woord, doel_woord, positie,
  moeilijk, rol}`). Alleen gevuld voor ingelogde eigenaren/deelnemers (openbare
  sets) of, bij privésets, na de bestaande privacycheck; anders `null`.

### Rollenspel (`/api/rollenspel`)
Deze router laadt altijd mee (`main.py`), niet achter `ZEGT_OFFLINE`, en elke
route vereist login. Een rollenspel is een set (`soort="rollenspel"`) waarvan de
`Woord`-rijen vaste gespreksbeurten zijn; de `rol` van een beurt bepaalt wie hem
zegt. De doeltaal van de leerling-beurten blijft verborgen: de leerling ziet
alleen de Nederlandse zin en moet de doeltaal zelf zeggen.

- `GET /sets/{slug}` — het scenario. **404** als de set niet bestaat, geen
  rollenspel is, of de gebruiker geen toegang heeft (eigen set, openbare set
  voor een ingelogd niet-gastaccount, of een geregistreerde deelname). Antwoord `{naam, slug, doel_taal, bron_taal, aantal, beurten}`;
  elke beurt is `{id, rol, nl}` en voor `tegenstander`-beurten ook `doel` (de
  doeltaalzin die wordt voorgelezen). Voor `leerling`-beurten wordt `doel` bewust
  **niet** meegestuurd.
- `POST /sets/{slug}/beurt/{beurt_id}/spraak` — multipart `audio` (+ optioneel
  `lokaal_gereed`, standaard `true`) beoordeelt één leerling-beurt tegen de
  verborgen doeltaalzin via de lokale engine (`beoordeel_lokaal`; een RIFF/WAV-kop
  van 44 bytes wordt eraf gestreken, > 2 MB → **400**, < ~200 ms PCM → **422**).
  Rate limit `rollenspel-spraak:{leerling_id}` 60/min. Antwoord
  `{beurt_id, score, goed, oordeel, reden, tip, gehoord, fouten}`. De beurt
  gebruikt hetzelfde getrapte oordeel als het woord-oefenen
  (`uitspraak_feedback.classificeer_uitspraak`), dus `goed` is alleen waar bij
  `oordeel == "goed"`; `reden` kan `ander_woord`, `onduidelijk`, `ontbrekend`,
  `klankfout` of `te_zwak` zijn en de `fouten` krijgen dezelfde leesbare
  `klank`/`uitleg`-velden als bij het spreken, maar zonder `oefen` (de oefenzin
  hoort bij het woord-oefenen en komt daar pas vanaf de tweede poging mee).
  **400** bij een tegenstander-beurt ("Deze beurt is niet voor jou"), **422**
  als de beurt geen doeltaal heeft, **404** bij een onbekende beurt, **503**
  bij engine-drukte (`Retry-After`) of een nog opwarmende engine en **502**
  bij een andere enginefout.

### Docent (`/api/dashboard`, `/api/klassen`, `/api/leerlingen`)
- Klassen hebben een eigenaar (`Klas.docent_id`). Een docent ziet en beheert
  alleen zijn eigen klassen (`/api/klassen`, `/{id}/leerlingen`,
  `/api/dashboard/klas/{id}`, `/api/dashboard/klassen/{id}/export`,
  `/api/dashboard/klanken?klas_id=`/`?leerling_id=`); de beheerder ziet alles.
  Een docent mag een leerling alleen aan zijn klas toevoegen als die leerling al
  aan hem gekoppeld is (eigen klas of deelname aan een eigen set).
- Dashboard: `overzicht`, `klas/{id}`, `lijst/{id}` zijn voor docenten.
  `leerling/{id}`, `aanmeldingen` en het goedkeuren/weigeren van een
  docent-aanvraag zijn **alleen voor de beheerder** (`vereis_admin`), net als
  `GET /api/leerlingen/{id}` en `DELETE /api/leerlingen/{id}`.
- Leerlingen: de volledige lijst en een volledig account (inclusief
  e-mailadres) zijn alleen voor de beheerder.

### Delen (`/api/delen`)
- `GET /{token}` — info over een deellink, inclusief de vaste oefenmodus
  (`modus`, `None` = standaard slimme route); `POST /{token}/claim` — claimen
  (maakt een gastaccount als er geen sessie is) en geeft die modus terug onder
  `lijst.modus`.

### AI (`/api/ai`)
- `POST /foto` — multipart: één UploadFile `afbeelding` (max. 8 MB) +
  Form-velden `soort` (`woordjes`|`rollenspel`), `bron_taal`, `doel_taal`.
  Eén foto gaat rechtstreeks naar Mistral/Pixtral (`pixtral-12b-2409`,
  temperature 0, strenge OCR-systemprompt: alleen letterlijk transcriberen,
  onduidelijk weglaten, nooit vertalen/aanvullen), na moderatie via Azure
  Content Safety. Bij `soort=woordjes`:
  `{"woorden":[{bron,doel}], ...taaldetectie (lokaal fastText)}`; bij
  `soort=rollenspel`: `{"regels":[{tekst, rol: "leerling"|"tegenstander"}]}`
  (puur getranscribeerde dialoogregels, niets vertaald of verzonnen).
  **501** zonder API-key, **502** bij een AI-fout. Rate limit 20/u per
  gebruiker.
- `POST /vertaal-zinnen` — body `{zinnen:[str]}` (max 100 zinnen à 400 tekens;
  lege zinnen worden weggefilterd) → `{"vertalingen":[str]}`: letterlijke
  batchvertaling naar het Nederlands, in dezelfde volgorde
  (`mistral-small-latest`). **400** zonder zinnen, **501**/**502** bij fouten.
  Rate limit 60/u per gebruiker.
- `POST /detecteer-taal`, `POST /sorteer-woorden` (onveranderd).

### Instellingen (`/api/instellingen`)
- `azure-modus`, `weergavenaam`, `docent-aanvragen`, `email-wijzigen`
  (aanvragen/valideren/bevestigen), `verwijderen` (aanvragen/valideren/bevestigen).
- `email-wijzigen` vereist het huidige wachtwoord (of een recent doorlopen
  tweede factor); een gestolen sessiecookie alleen is niet genoeg.
- De 2FA-inschrijving (`/api/beveiliging/totp/setup`,
  `/api/beveiliging/passkey/registreren-start`, `POST /api/beveiliging/email-2fa`
  met `ingeschakeld=true`) vereist dezelfde herbevestiging.

### Gesprek (`/api/gesprek`) — testfase
De gespreksmodus ("Gesprek") is een nog niet openbare functie. De router laadt
altijd mee (`main.py`). De rechten zijn gesplitst:

- **Ontwerpen (alleen beheerder/docent):** `POST /ontwerp`, `POST /profiel` en
  `POST /ontwerp/screenshot` eisen `vereis_admin` (`_eis_ontwerp`) én dat de
  systeemschakelaar `app_instellingen.gesprek_aan` aan staat; anders **403**
  ("Gespreksmodus staat uit").
- **Lezen en gesprek voeren (ingelogde gebruiker):** `GET /sets/{slug}/draaiboek`,
  `POST /sessie`, `POST /sessie/{id}/bericht`, `POST /sessie/{id}/spraak`,
  `POST /sessie/{id}/hint`, `GET /sessie/{id}` en
  `POST /sessie/{id}/afronden` controleren via `_eis_gebruik` of
  `gesprek_aan` aan staat, plus het sessie-eigenaarschap. Voor de set geldt
  dezelfde toegangsregel als bij het oefenen: eigen set, openbare set voor een
  ingelogd niet-gastaccount, of een geregistreerde deelname. Er is geen aparte
  gast-blokkade (`vereis_geen_gast`); een gast komt alleen via een deellink
  binnen en is dan deelnemer. De systeemprompt en doelwoorden zijn
  docentmateriaal en worden alleen aan de beheerder teruggegeven.

- `GET /status` — **geen login nodig** en geeft nooit 5xx (fouten worden
  afgevangen). Antwoord `{"actief": bool, "mag_testen": bool}`: `actief` = de
  systeemschakelaar, `mag_testen` = of de sessie een beheerder is. Met
  optioneel `?set=<slug of id>` komt er ook `heeft_draaiboek: true|false` bij
  (alleen gezet als de set bestaat en de schakelaar aan staat). De SPA gebruikt
  dit om de Gesprek-optie in het oefenmodus-blad alléén te tonen als `actief`
  én `heeft_draaiboek` — een kale woordenschatset krijgt geen Gesprek-kaart.
- `GET /sets/{slug}/draaiboek` — (login + schakelaar aan) het laatste actieve
  draaiboek van de set. Antwoord `{"heeft_draaiboek": bool, "draaiboek":
  null | {"id", "mag_ontwerpen"}}`; alleen voor de beheerder komen daar
  `systeem_prompt` en `doelwoorden` (uit JSON geparsed) bij.
- `POST /ontwerp` — body `{lijst_id, bericht, geschiedenis, finaliseer}`
  (beheerder + schakelaar). Een echte **meerronde chat** met Mistral
  (`GESPREK_MODEL`, standaard `mistral-large-latest`) die samen met de docent
  een **systeemprompt** ontwerpt; er is geen geforceerde afronding meer. De
  assistent praat in de brontaal van de set en schrijft de systeemprompt ook in
  de brontaal, maar draagt de gespreks-AI op om tijdens het gesprek uitsluitend
  de doeltaal te gebruiken. Met `finaliseer=true` vraagt de docent expliciet om
  de prompt nu vast te leggen (de "Stel definitieve prompt voor"-knop in
  `static/js/maken.js`). Dit ontwerp gebeurt in het bewerkscherm van de set,
  niet op de gesprekspagina. Rate limit `gesprek-ontwerp:{leerling_id}` 30/uur.
  Antwoord `{antwoord, klaar, systeem_prompt, doelwoorden}`; meldt het model
  `klaar` mét een `systeem_prompt`, dan worden `systeem_prompt` en een
  JSON-snapshot van **alle** setwoorden (incl. alternatieven) in `doelwoorden`
  opgeslagen in `gesprek_draaiboeken` (upsert). **502** bij een Mistral-fout.
- `POST /profiel` — body `{lijst_id, systeem_prompt, doelwoorden?}`
  (beheerder + schakelaar). De docent zet de systeemprompt zelf vast. Zonder
  `doelwoorden` wordt het huidige snapshot van alle setwoorden bewaard; de
  oefenwoorden worden tijdens het gesprek automatisch achter de prompt gezet.
  Antwoord `{systeem_prompt, doelwoorden}`.
- `POST /ontwerp/screenshot` — multipart `afbeelding` (max. 8 MB) + `lijst_id`
  (beheerder + schakelaar). Pixtral (`pixtral-12b-2409`) haalt de leesbare tekst
  uit de afbeelding. Antwoord `{tekst}`; **400** bij een lege/te grote
  afbeelding, **502** bij een fout. Geen eigen rate limit.
- `POST /sessie` — body `{lijst_id}` of `{slug}` (login + schakelaar). Start
  een `GesprekSessie`; zonder actief draaiboek **404**. Rate limit
  `gesprek-sessie:{leerling_id}` 30/uur. Genereert de eerste AI-zin in de
  doeltaal via `gesprek_antwoord`. Antwoord
  `{sessie_id, app_tekst, berichten, klaar}`; **502** bij een Mistral-fout.
- `POST /sessie/{id}/bericht` — body `{tekst}`; de getypte beurt van de
  leerling (tekst-fallback naast spraak). Rate limit
  `gesprek-bericht:{leerling_id}` 300/uur. Antwoord
  `{gehoord: null, app_tekst, berichten, aantal_beurten}`; **422** bij een leeg
  bericht.
- `POST /sessie/{id}/spraak` — multipart `audio` (max. 2 MB; een RIFF/WAV-kop
  van 44 bytes wordt eraf gestreken; minder dan ~200 ms PCM → **422**). De
  leerling spreekt vrij in de doeltaal en de engine levert een **open
  transcriptie** via `lokale_engine.transcribeer_lokaal` (engine-endpoint
  `POST /transcriptie`) — dus géén referentie-scoring. Rate limit
  `gesprek-spraak:{leerling_id}` 240/uur. Antwoord
  `{gehoord, app_tekst, berichten, aantal_beurten}`. **400** bij een opname
  > 2 MB, **503** bij drukte of een nog opwarmende engine
  (`EngineBezet`/`EngineNietBereikbaar`), **422** bij een te korte of lege
  opname/transcriptie en **502** bij een andere enginefout.
- `POST /sessie/{id}/hint` — geeft op verzoek één hint in de brontaal (via
  `gesprek_hint`); telt niet mee als gespreksbeurt. Rate limit
  `gesprek-hint:{leerling_id}` 60/uur. Antwoord `{hint, bericht}`.
- `GET /sessie/{id}` — de volledige chatgeschiedenis van een sessie (om na
  verversen te hervatten): `{sessie_id, status, berichten, aantal_beurten}`.
- `POST /sessie/{id}/afronden` — zet de sessie op `klaar` en geeft
  `{gebruikte_woorden, niet_gebruikte_woorden, aantal_beurten}`; elk woord is
  `{id, bron_woord, doel_woord}`.

De AI krijgt bij elke beurt de **effectieve systeemprompt**: de door de docent
ontworpen `systeem_prompt` plus een blok met **alle** setwoorden (incl.
alternatieven) als oefenwoorden (`_effectieve_prompt` in `routers/gesprek.py`).
De server herkent in de leerlingbeurten welke setwoorden voorkomen en bewaart
die per bericht (`gebruikte_woord_ids`); aan het eind worden de gebruikte en
niet-gebruikte woorden teruggekoppeld. Er is geen SRS-filtering meer op de
gesprekswoorden: alle setwoorden doen mee.

De systeemschakelaar zit in de database, niet in `.env`:

- `GET /api/admin/gesprek` — (beheerder) `{"gesprek_aan": bool}`.
- `POST /api/admin/gesprek` — (beheerder) body `{"aan": bool}`; zet de
  schakelaar en schrijft audit `gesprek_schakelaar`.

### Snelheid (`/api/snelheid`) — publiek
De publieke pagina `/snelheid` toont hoe snel de eigen engine op dit moment
antwoordt en laat een bezoeker een begrensde stresstest starten. Er is geen
login nodig. De test gebruikt een door de TTS-dienst gesynthetiseerde oefenzin
(nooit gebruikersaudio) en bewaart alleen anonieme tijdmetingen en
HTTP-statuscodes — geen IP's of gebruikersgegevens. Geen enkele route schrijft
naar de database.

- `GET /status` — actuele status. Antwoord: `engine` (`aan` = `LOKALE_ENGINE_AAN`,
  `bereikbaar`, plus `actief`/`wachtend`/`max_gelijk` uit de engine-`/health`),
  `monitor` (`aan` = `SNELHEID_MONITOR_AAN`, `interval_s`, `mediaan_ms` en
  anonieme `samples[]`), `laatste_test` (samenvatting van de laatst gedraaide run
  of `null`), `actief`/`run_id` (loopt er een test), `taal` (`SNELHEID_TAAL`),
  `talen` (`en`, `nl`, `de`, `fr`, `es`, `it`, `pt`) en `limieten`
  (`max_parallel` 100, `max_aantal` 100).
- `POST /stress` — start één test. Body `{parallel (1-100, standaard 8),
  aantal (1-100, standaard 20), taal (standaard `SNELHEID_TAAL`)}`. Elke
  beoordeling draait de volledige wav2vec2-pijplijn; vóór de meting doet de run
  één ongetelde opwarm-aanvraag zodat de referentiecache niet de cijfers
  vertekent. Publiek maar
  **CSRF-plichtig** (niet in `CSRF_VRIJE_PREFIXES`) en per IP gelimiteerd op
  5 per 10 min. **503** als `LOKALE_ENGINE_AAN` uit staat, **400** bij een
  onbekende taal, **409** als er al een test loopt (één run tegelijk). Antwoord
  `{run_id, taal, parallel, aantal}`.
- `GET /stress/{run_id}` — live voortgang: `id`, `bezig`, `gestart`, `klaar`,
  `taal`, `parallel`, `aantal`, `resultaten[]` (per verzoek `ms`, `status`, `ok`
  en `i`), `samenvatting` (min/mediaan/p95/max/gemiddeld, `doorvoer_per_s`,
  `gelukt`, `aantal`, `wand_seconden`) en `fout`. Onbekend of verlopen → **404**.

De achtergrondmonitor (`routers/snelheid.py:monitor_lus`) doet elke
`SNELHEID_MONITOR_INTERVAL_S` (standaard 90 s; minimaal 15 s) één anonieme
meting en start in de lifespan van `main.py` als `SNELHEID_MONITOR_AAN` aan
staat.

### Status (`/api/status`) — publiek
De systeemstatus van de site: staat de uitspraakserver (engine + TTS-dienst op
de engine-host) uit? Geen login nodig en het endpoint geeft nooit 5xx — bij een
DB-fout antwoordt het alsof de server aan staat. De SPA haalt het bij het
opstarten (ook vóór login) en daarna elke 2 minuten op en toont dan bovenaan
de site een korte melding (`SysteemMelding` in `js/app.js`; in de
offline/desktopmodus doet de client bewust niets). De router laadt altijd mee.

- `GET /status` — `{"uitspraak_uit": bool, "titel": str|null, "tekst": str|null}`.
  Bij `uitspraak_uit: true` staan er een vaste Nederlandse titel ("De
  uitspraakserver is even offline") en uitleg in (beoordelen kan even niet,
  voorlezen/typen/meerkeuze werken gewoon); anders zijn `titel` en `tekst`
  `null`.

De schakelaar is een DB-rij (`AppInstelling.uitspraak_uit`, net als de
gespreksmodus niet in `.env`); de gelezen stand wordt 5 seconden per proces
gecachet (`uitspraak_status.py`):

- `GET /api/admin/uitspraak` — (beheerder) `{"uit": bool}`.
- `POST /api/admin/uitspraak` — (beheerder) body `{"uit": bool}`; zet de
  schakelaar, ververst de procescache en schrijft audit `uitspraak_schakelaar`.

Staat de schakelaar uit, dan slaat de webapp de engine- en TTS-host op de
engine-host meteen over (geen timeouts; voorlezen valt direct terug op het
Azure-vangnet — zie `/llm/uitspraak-engine.md`). De spreekbeoordeling gaat dan
meteen naar het vangnet als de leerling Azure heeft aangezet; zonder vangnet
(incl. meerdere referenties) volgt **503** met de eigen melding "Onze
uitspraakserver staat tijdelijk uit. Probeer het later opnieuw." (i.p.v. een
misleidende "beoordeling mislukt"). `GET /api/admin/status` (beheerder) toont
bij `engine` dan `aan: false` en `uitgezet: true` (de bereikbaarheidscheck
wordt overgeslagen).

### Demo (`/api/demo`) — publiek
Twee losstaande publieke pagina's gebruiken deze endpoints: `/home` (demo op
de thuispagina) en `/praat` (spraaktest, zie hieronder). Geen login, geen
sessie en dus CSRF-vrij (`CSRF_VRIJE_PREFIXES`); er wordt niets in de database
geschreven. Alleen een afgebakende set talen wordt aangesproken (`nl-NL`,
`nl-BE`, `en-GB`, `en-US`, `fr-FR`, `de-DE`, `es-ES`; onbekende talen vallen
terug op `nl-NL`). Alle endpoints zijn per IP gelimiteerd.

- `POST /uitspraak` — multipart `audio` + `tekst` (+ `taal`, standaard
  `nl-NL`); 12/min per IP. Antwoord: `is_correct`, `score`, `nauwkeurigheid`,
  `vloeiendheid`, `herkende_tekst` en een Nederlandse `feedback`-tekst. De
  opname wordt niet bewaard (thuispagina-demo).
- `POST /tts` — JSON `{tekst, taal?}` → `audio/wav`; 30/min per IP.
  Voorbeeldaudio (Piper) om het woord voor te horen.
- `POST /diagnose` — multipart `audio`, `doel`, `taal`, `kandidaten`
  (JSON-lijst verwarwoorden, max. 8), `akkoord` en `soort`; 120 per 15 min
  per IP. **Zonder `akkoord`-vlag: 400** — bewaren gebeurt uitsluitend met
  expliciete toestemming ("Ik ga akkoord" op de spraaktestpagina). Verder
  **asynchroon**: de opname wordt direct bewaard en op de achtergrond tegen
  het doelwoord én alle kandidaten beoordeeld (één engine-aanroep per woord);
  antwoord **202** met `{job_id, status, voortgang: {gedaan, totaal},
  pogingen, fout, opname_url, resultaat}`. Bij engine-drukte of timeout
  blijft de job gewoon staan en gaat hij vanzelf verder zodra het weer kan —
  de opname raakt nooit kwijt.
- `GET /diagnose/{job_id}` — status/uitslag pollen; 1200/min per IP. `status`
  is `bezig`/`wacht`/`klaar`/`fout`. Bij `klaar` bevat `resultaat` o.a.
  `doel`, `doel_rang` (1 = doelwoord scoort het hoogst), `marge`,
  `test_oordeel` (`ok`/`twijfel`/`fout`) en `resultaten[]` met per woord
  `score`, `oordeel`, `herkend`, `herkende_tekst`, `nauwkeurigheid_incl` en
  `detail` met de foneem-evidence (verwacht tegenover gehoord). Een job die
  door een herstart of deploy zonder lopende taak is blijven staan, wordt
  hier automatisch hervat. Onbekend/verlopen → **404**.
- `POST /diagnose/{job_id}/opnieuw` — de bewaarde opname opnieuw laten
  beoordelen (pogingen en resultaat gereset); 120 per 15 min per IP.
- `GET /opname/{job_id}` — de bewaarde opname als `audio/wav` (`no-store`);
  300/min per IP.
- `GET /bijdragen` — publieke teller `{totaal, per_taal}`: hoeveel opnames al als
  trainingsvoorbeeld zijn bijgedragen. Alleen aantallen, geen opnames of
  persoonsgegevens; 120/min per IP.

#### Spraaktest `/praat` — "help Zeg 't beter maken"
De losstaande publieke pagina `/praat` (alias-URL's `/spraak`, `/spraaktest`
en `/beoordelingstest`; `noindex`, geen login) laat bezoekers moeilijke woorden
nazeggen; goed uitgesproken opnames worden — na expliciet akkoord — gebruikt
om de uitspraakherkenning te verbeteren (dat staat ook zo op de pagina, die
naar `/privacy` verwijst). Zonder akkoord is de startknop uitgeschakeld en
weigert `POST /api/demo/diagnose` (400).

- **Rotatie:** elke ronde trekt 10 willekeurige woorden (`RONDE = 10` in
  `static/js/praat.js`) uit een lijst per taal (taalfilter op de pagina). De
  vier woorden mét verwarwoorden (de diagnostische kern) doen altijd mee, de
  rest rouleert; naast de kernwoorden heeft elke taal een `EXTRA`-lijst
  precies op de zwakke klankklassen (NL `ɣ` g/ch, FR `ø/œ` + nasalen,
  DE `ö/ü/ç`, ES `ʎ` + jota). Knop "Nieuwe ronde (andere woorden)".
- **Teller:** de pagina toont hoeveel opnames al zijn bijgedragen
  (`GET /api/demo/bijdragen`).
- **Oogst (trainingsdata):** een opname komt alléén in de trainingsset als
  het model het doelwoord overtuigend hoorde: oordeel `goed`, `score >= 85`,
  `nauwkeurigheid_incl >= 80` én niet als onherkenbaar gemarkeerd.
  Twijfelgevallen worden níet als trainingsdata bewaard. De kopie staat in
  `data/praat-opnames/bijdragen/` met een indexregel in `bijdragen.jsonl`:
  `{tijd, id, taal, soort, doel, score, gehoord, audio}` — geen IP, geen
  account, geen naam. Begrensd op max. 20.000 clips (`OOGST_MAX`; daarboven
  vallen de oudste weg) en een bewaartermijn van 180 dagen (`OOGST_DAGEN`;
  oudere opnames worden verwijderd, ook door de dagelijkse onderhoudstaak). De
  diagnose-opnames zelf (job-wav + job-status onder
  `PRAAT_OPNAME_DIR`, standaard `data/praat-opnames`) worden na 14 dagen of
  bij meer dan 200 jobs opgeruimd; per afgeronde beoordeling wordt één
  compacte resultaatregel gelogd (`resultaten.jsonl`, zonder audio, IP of
  account).

### Sync (`/api/sync`)
- `POST /voortgang` — de wachtrij met offline geoefende resultaten verwerken
  (max. 200 per keer; idempotent via `uuid`).
- `POST /set-starts` — de wachtrij met offline set-starts verwerken (max. 200 per
  keer). Payload `{"starts": [{"uuid", "slug", "tijdsstempel"}]}`, antwoord
  `{"verwerkt", "overgeslagen", "fouten": [{"uuid", "fout"}]}`. Elke nieuwe
  `uuid` verhoogt `Woordenlijst.aantal_geoefend` met exact 1; een herhaalde
  `uuid` wordt overgeslagen. Zo gaan offline set-tellers **additief** naar online
  (geen overwrite/max). Zelfde toegangsregel als
  `POST /api/woordenlijsten/{id}/geoefend`.
- `GET /gebeurtenissen?na=&limiet=` — online→offline-export: oefenpogingen met
  `id > na` (cursor), `limiet` 1-500 (standaard 200). Antwoord:
  `{"gebeurtenissen": [...], "cursor": int, "sets": [...]}`; `sets` bevat
  additief de eigen sets als `{id, slug, aantal_geoefend}`, zodat de
  offline-database de teller met `max()` kan overnemen (oude clients negeren het).
- `POST /voortgang-staat` — tweerichtings-uitwisseling van de volledige
  SRS-staat per set/woord (rate limit 60/min). Body
  `{"sets": [{"slug", "aantal_geoefend", "woorden": [{"server_woord_id",
  "niveau", "streak", "gemasterd", "herhalingen_nog", "volgende_herhaling",
  "laatst_geoefend"}]}]}`. Alleen sets waarvan de leerling eigenaar of deelnemer
  is; max. 200 sets en 5000 woorden per set. Per set geldt `max(lokaal, online)`
  voor `aantal_geoefend`; per woord wint de verst gevorderde SRS-staat (eerst
  hoger `niveau`, dan `gemasterd`, dan `streak`, dan nieuwere `laatst_geoefend`),
  zodat voortgang nooit achteruitgaat (monotone merge). Het antwoord
  `{"sets": [...], "sets_bijgewerkt", "woorden_bijgewerkt"}` bevat per set
  **alle** geoefende woorden van die set (niet alleen de meegestuurde), zodat
  beide kanten in één ronde convergeren. Er komen geen nieuwe tabellen bij: de
  staat-merge gebruikt de bestaande `srs_voortgang`.

### Engine-brug (`/api/engine`) — stateless, login vereist
De webapp van de offline desktopbundel gebruikt deze routes als online engine
zolang het lokale taalmodel nog laadt. De routes schrijven niets in de database
en staan daarom in `CSRF_VRIJE_PREFIXES`; de productie-inlogsessie wordt als
cookie meegegeven.

- `GET /health` — controle dat de brug bereikbaar en de sessie geldig is.
- `POST /uitspraak` — form `audio` (bestand, max. 2 MB), `tekst`,
  `taal` (standaard `en`). Roept de eigen engine aan en geeft het ruwe
  engine-resultaat terug. **503** bij drukte of opwarmen, **502** als de
  beoordeling mislukt.
- `POST /tts` — form `tekst`, `taal` (standaard `en-US`) → `audio/wav`. Max. 600
  tekens; **502** bij een fout.

### Offline (`/api/offline`) — alleen in offline-modus (`ZEGT_OFFLINE`) en via loopback
- `GET /start`, `GET /importeren`, `GET /status`, `GET /audio/{woord_id}` —
  lokale modus, set-zips importeren en audio serveren. De `set.json` in zo'n zip
  bevat onder `set` ook `aantal_geoefend` en `inhouds_hash`; bij importeren wordt
  die teller overgenomen en nooit verlaagd, en wordt de inhoudsversie per
  (leerling, slug) in `offline_set_versies` vastgelegd. Een ongewijzigde zip
  wordt op basis van zijn SHA-256 (tabel `offline_imports`) overgeslagen en als
  `overgeslagen` in de respons geteld; een gewijzigde zip wordt opnieuw verwerkt.
- `GET /set-versies` — de vastgelegde inhoudsversies van de geïmporteerde sets:
  `{"sets": [{slug, server_id, inhouds_hash, aantal_woorden, bijgewerkt_op}]}`.
  De desktop-host vergelijkt deze met de online `inhouds_hash`
  (`GET /api/woordenlijsten`) om te bepalen of een lokale set opnieuw
  gedownload moet worden.
- `GET /desktop-health` — minimale rooktest/health van de lokale desktopstack
  (geen database-toegang); antwoordt exact `{"status": "ok", "offline": true}`.
  Het verificatiescript van de desktop-host
  (`zegt_desktop_host/test-desktop-local-first.ps1`) pollt dit.
- `GET /voortgang`, `POST /voortgang/bevestig` — de offline→online-wachtrij.
- `GET /set-starts`, `POST /set-starts/bevestig` — de offline→online-wachtrij met
  set-starts (`{uuid, slug, tijdsstempel}`); `bevestig` verwijdert de meegegeven
  `uuid`s.
- `GET /voortgang-staat`, `POST /voortgang-staat/importeer` — volledige
  SRS-staat-uitwisseling naast de wachtrij. `GET` geeft de lokale SRS-staat per
  set/woord (met `server_woord_id`); `importeer` past de door de server
  samengevoegde staat lokaal toe met dezelfde "verst gevorderde wint"-regel
  (idempotent, voortgang gaat nooit achteruit) en antwoordt
  `{"woorden_bijgewerkt", "sets_bijgewerkt"}`. Max. 500 sets en 5000 woorden per
  set.
- `GET /sync/status` — `laatste_gebeurtenis_id`, `wachtend` en
  `set_starts_wachtend`.
- `POST /sync/importeer` — online→offline: de import is
  additief en idempotent (dedup op `server_poging_id`); gebeurtenissen met
  `beoordelings_engine = "offline"` worden overgeslagen en flitskaarten raken de
  SRS-ladder niet. De body mag naast `gebeurtenissen` en `cursor` ook een
  optionele `sets`-lijst `{id, slug, aantal_geoefend}` bevatten; per slug wordt
  `max()` toegepast (de online teller blijft zo een plafond) en het antwoord telt
  de gewijzigde sets in `sets_bijgewerkt`. De offline→online-richting telt juist
  additief op via `/api/sync/set-starts`.
- `GET /engine/status` — de lokale uitspraak-engine-status; bevat ook
  `online_brug` (bool: of de online brug bruikbaar is). `POST /engine/voorbereiten`
  (form `taal`) start het laden van de basis + het taalmodel.
- `POST /engine/brug/sessie` (JSON `{"cookie": "..."}`) — bewaart de
  productie-sessiecookie waarmee de lokale webapp de online engine-brug mag
  gebruiken; antwoordt `{"ok", "online_brug"}`.
- `GET /online/status` — leest met de bewaarde brug-cookie
  (zie `POST /engine/brug/sessie`) `<PRODUCTIE_BASIS>/api/auth/me` en geeft
  `{"ok": true, "ingelogd": bool, "account": <json|null>, "fout": <str|null>}`.
  Zonder brug-cookie (of bij 401/403) direct `ingelogd: false` zonder
  netwerkcall; een netwerk-/timeoutfout geeft HTTP 200 met een korte
  Nederlandse melding in `fout` — nooit 5xx.
- `GET /online/sets` — haalt met dezelfde brug-cookie
  `<PRODUCTIE_BASIS>/api/woordenlijsten?mijn=true` op en geeft
  `{"ok": true, "ingelogd": bool, "sets": [...], "fout": <str|null>}`; zonder
  cookie of bij 401/403 een lege lijst. De `sets`-array (met de server-id's)
  komt verbatim van zeg-t.nl en voedt de sectie "Sets op zeg-t.nl" in de
  desktopapp. `PRODUCTIE_BASIS` is de basis-URL (standaard `https://zeg-t.nl`).

### Mobiel-offline (native Android)

Twee routers voor de native Android-app staan **altijd** in de app (ook online),
niet achter `ZEGT_OFFLINE` en zonder loopback-beperking:
`routers/offline_bundel.py` (`/api/woordenlijsten/{id}/offline-bundel`) en
`routers/mobiel_offline.py` (`/api/offline/modellen`, `/modellen/vocab`,
`/modellen/vad`, `/modellen/{code}`, `/scoring`). De set-bundel vereist een
geldige login zonder gastaccount; de model- en scoringsendpoints van
`routers/mobiel_offline.py` zijn **publiek vóór login** (de onboarding van de
app draait vóór het inloggen) met een lichte IP-rate-limit van 60/min. Het
bestaande `/api/offline/*`-gedrag van de desktopbundel (`routers/offline.py`,
`routers/offline_engine.py`, `routers/online.py`) is ongewijzigd en blijft
alleen in `ZEGT_OFFLINE` + loopback actief.

- `GET /api/woordenlijsten/{id}/offline-bundel` (ook `?slug=…`; `{id}` mag een
  numeriek id of een slug zijn) — manifest waarmee de app een set offline kan
  oefenen. Zelfde auth/toegang als `/{id}/download` (maker of geregistreerde
  deelname); rate limit `offline-bundel:{leerling_id}` 10/60. Antwoord:
  `bundel_versie` (1), `model_versie` (`MOBIEL_MODEL_VERSIE`, standaard
  `onnx-int8-v2`), `referentie_backend` (bijv. `v4-onnx:gtts:com`, of
  `v5-onnx-phoneemb:…` met het gecombineerde model, uit
  de engine), `inhouds_hash` (dezelfde 32-tekens set-hash als
  `GET /api/woordenlijsten`), `slug`, `naam`, `bron_taal`, `doel_taal`,
  `aantal_woorden`, `basis_pad`
  (`/api/woordenlijsten/{id}/offline-bundel/bestand`) en `woorden[]` met
  `server_woord_id`, `bron_woord`, `bron_alt`, `doel_woord`, `doel_alt`,
  `positie`, `moeilijk`, `referenties[]` en `meerkeuze_opties`.
  - Elke referentie is een acceptabel antwoord (net als de online spreekroute:
    `hoofd`, `alt1`, `alt2`, … en `samen` = alle alternatieven achter elkaar) met
    `variant`, `tekst` en de relatieve paden `wav`, `ipa`, `emb`, `f0`
    (`ref/{id}-{variant}.{ext}`).
  - `meerkeuze_opties` is deterministisch uit de set zelf afgeleid (max. 3
    afleiders + het juiste antwoord, stabiel per setinhoud + woord); zijn er
    minder dan 2 opties, dan is de lijst leeg.
- `GET /api/woordenlijsten/{id}/offline-bundel/bestand?pad=ref/{id}-{variant}.{ext}`
  — levert één referentiebestand met `Content-Length`, `ETag` (sha256),
  `Accept-Ranges: bytes` en HTTP `Range` (206/416); een `If-None-Match` met
  dezelfde ETag geeft **304** zonder de bytes opnieuw te sturen. Rate limit
  `offline-bundel-bestand:{leerling_id}` 1200/60. Ontbreekt het bestand op de
  VPS, dan bouwt de webapp het via de engine en cachet het onder
  `MOBIEL_BUNDEL_DIR/bundels/<sleutel>` (sleutel = sha256 van
  referentiebackend + doeltaal + setinhoud); de map wordt hergebruikt tussen sets
  met dezelfde inhoud. De webapp houdt daarnaast maximaal 256 verwijzingen naar
  zulke cachemappen in procesgeheugen bij; de bestanden zelf staan op schijf.
  `pad` wordt strikt gevalideerd (`ref/{id}-{variant}.{ext}`). Formaten:
  - `.wav` — 16 kHz mono 16-bit PCM, de referentie-TTS van de engine
    (standaard gTTS). **Let op:** dit is niet dezelfde audio als
    `POST /api/oefenen/tts` (dat is Piper via de TTS-dienst op de engine-host).
  - `.emb` — float16 little-endian, embeddingmatrix `[T, 1024]`.
  - `.f0` — float16 little-endian, 1-D toonhoogtecontour.
  - `.ipa` — JSON-array van arrays: per woord de genormaliseerde fonemen die de
    scoring vergelijkt.

- `GET /api/offline/modellen` — model-manifest voor de app (publiek). De binaire
  modellen komen rechtstreeks van de HuggingFace-repo `MOBIEL_HF_REPO` (standaard
  `zegt/uitspraak-onnx-int8`; `manifest.json` met bytes + sha256); de VAD levert
  de VPS zelf. Antwoord: `manifest_versie` (2), `model_versie`
  (`MOBIEL_MODEL_VERSIE`, standaard `onnx-int8-v2`), `repo`,
  `uitlevering: "huggingface"`, `basis_url`, `modellen[]`, `per_taal` en `vocab`.
  Elk item van `modellen[]` heeft `code`, `variant`, `rol`, `versie` (`sha256-…`),
  `bytes`, `formaat` (`onnx-int8`/`onnx`) en `url`; het gecombineerde model heeft
  daarnaast `outputs: {ctc: "logits", embeddings: "embeddings"}`. De VAD-entry
  heeft `code: "vad"`, `variant: "silero"`, `rol: "vad"` en `versie`/`bytes` uit
  het bestand dat de VPS cachet. `per_taal` bevat per taal (`nl`, `en`, `de`,
  `fr`, `es`, `it`, `pt`) `verplicht` en `optioneel` (`ctc_<taal>`), plus
  `variant_standaard` en `varianten` (per variant `extra_bytes`/`model_bytes`).
  Sinds modelversie `onnx-int8-v2` is `phone` het gecombineerde
  `phone_emb`-model (CTC + embeddings), zodat `verplicht` uit `phone` en `vad`
  bestaat en het losse `emb_en` vervalt; de legacy-grafen `phone`/`emb_en`
  verschijnen alleen zolang `phone_emb` in de HF-repo ontbreekt. `vocab` bevat
  `url` en, indien beschikbaar, `sha256`. De opgehaalde HF-manifest-,
  vocabulaire- en scoringsgegevens worden per webproces 6 uur in RAM gecached.
  Er wordt ook een JSON-kopie onder `MOBIEL_BUNDEL_DIR` bewaard; bij een storing
  van HuggingFace of de engine valt de server daarop terug.
- `GET /api/offline/modellen/vocab` — proxy van engine `/offline/vocab`:
  token-id → IPA (392 tokens) met `blank_id` (het CTC-pad-token),
  `special_tokens` en een sha256 over de tabel; valt bij een storing terug op de
  bewaarde kopie, anders 503.
- `GET /api/offline/modellen/{code}` (optioneel `?variant=…`) — één modelbestand
  (publiek). Voor `vad` levert de VPS de bytes zelf, met `Content-Length`,
  `ETag`, `Accept-Ranges: bytes`, `Range` (206/416) en `If-None-Match` → 304.
  Voor de zware HuggingFace-modellen volgt een **302-redirect** naar de HF-URL
  (HF ondersteunt `Range`). Onbekende code/variant → 404.
- `GET /api/offline/modellen/vad` — alias voor het Silero-VAD ONNX-model
  (`code=vad`), met dezelfde `Content-Length`, `ETag`, `Accept-Ranges` en
  `If-None-Match` → 304. De VPS bewaart de opgehaalde VAD-bytes onder
  `MOBIEL_BUNDEL_DIR/modellen/silero_vad.onnx`; de binaire respons heeft een
  publieke cacheduur van één jaar en is immutable.
- `GET /api/offline/scoring` — proxy van engine `/offline/scoring` (publiek),
  aangevuld met `model_versie`, `opname` (de client-side normalisatie uit
  `static/js/recorder.js`, zodat de app exact dezelfde audio aanlevert),
  `app` (`correct_drempel` = `LOKAAL_CORRECT_DREMPEL`; `ladder_drempels` met
  `goed`/`uitstekend` uit `srs.py`; `herhalingen_bij_fout`; `sessie_gap_s`) en
  `constanten_hash` (webapp-hash over het engine-antwoord). Het engine-deel heeft
  `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`+`emb_en`-grafen), `gewichten` (0.45/0.35/0.20), `herweging`
  (formule `(0.45 * nauwkeurigheid + 0.20 * volledigheid) / (0.45 + 0.20)` met
  gewichten 0.6923/0.3077 voor als het akoestische deel wegvalt),
  `akoestische_span` (12.0), `acoustic_ankers` (indicatieve DTW-ankers per taal
  voor het gecombineerde model én het oude `emb_en`), `talen` (per taal
  `acoustic_good`, `akoestische_span`, `espeak`, `asr_model`, `near_phones`,
  `phone_map` en `referentie_backend`), `foneemmodel`, `miscue`, `hints` en
  `voorverwerking` (sample rate, golfnormalisatie, VAD- en ruisdrempels).

## Opvallende details

- **Zelfregistratie logt direct in** — een nieuw account is meteen actief
  (`goedgekeurd` staat standaard aan); alleen een door de beheerder geblokkeerd
  account kan niet inloggen.
- **Docent worden** — via `POST /api/instellingen/docent-aanvragen`; de
  beheerder keurt de aanvraag goed (`/api/admin/docent-aanvragen/{id}/goedkeuren`).
- **Woorden worden nooit echt gewist:** verwijderen zet `lijst_id = NULL`
  (loskoppelen), zodat de voortgang bewaard blijft en terugkomt bij opnieuw
  toevoegen.
- **Gastaccounts** via deellink krijgen `anonieme_id = gast_<hex>`.
- **Azure-modus** (`leerling.azure_modus`, via `/api/instellingen/azure-modus`)
  is een **opt-in per leerling** voor de uitspraakbeoordeling: `niets`
  (standaard, Azure wordt nooit gebruikt), `fallback` (vangnet) of `steekproef`
  (vangnet + stille kwaliteitscontrole). Alleen het eigen account kan dit
  wijzigen; de route geeft **403** voor docent-accounts (Azure nooit), voor
  accounts onder de 16 (altijd uit) en voor niet-geverifieerde e-mailadressen
  (pas na e-mailverificatie). Nieuwe accounts beginnen op `niets`; bestaande
  voorkeuren blijven staan. Ook bij het spreken
  (`POST /api/oefenen/spreken/{id}`) dwingt de server deze regels af.
