# Architectuur

## Stack

| Laag | Keuze |
|------|-------|
| Backend | Python 3.11, FastAPI + Uvicorn |
| Database | PostgreSQL (productie) / SQLite (lokaal) via SQLAlchemy 2.0 |
| Frontend | Vanilla HTML/CSS/JS, single-page app, geen buildstap |
| Uitspraak | Eigen "OpenPronounce v2"-engine (leidend) + Azure Speech (vangnet, opt-in per leerling) |
| TTS | Piper (lokaal, leidend) + Azure Speech (vangnet voor talen zonder lokale stem) |
| AI | Mistral (pixtral-12b-2409 voor beeld, mistral-small-latest voor sorteren, `GESPREK_MODEL` — standaard mistral-large-latest — voor het gesprek) + fastText (lokaal, taaldetectie) |
| Moderatie | Azure Content Safety (afbeeldingen) |
| E-mail | SMTP via Poste.io |
| Deploy | Afzonderlijk applicatieproces achter een reverse proxy, beheerd met een process manager |

## Procesmodel

Eén FastAPI-app (`main.py`) bedient zowel de REST-API (onder `/api/`) als de
statische frontend. De SPA valt terug via een catch-all in `main.py`:
bekende publieke pagina's (o.a. `home`, `privacy`, `tos`, `leraren`,
`helpmee`, `snelheid`, `praat`) krijgen hun eigen HTML; verder wordt elk bestaand bestand uit
`static/` geserveerd en anders `index.html` (alles met `Cache-Control: no-cache`).

Belangrijke middleware in `main.py`, in deze volgorde:

1. **CORS** — expliciete `allow_origins` (alleen het eigen domein) met
   `allow_credentials=True`.
2. **CSRF-middleware** (eigen klasse, staat *binnen* de SessionMiddleware):
   alle POST/PUT/DELETE vereisen een geldige `X-CSRF-Token`-header, behalve de
   stateless prefixes `/api/demo/` en `/api/engine/` (`CSRF_VRIJE_PREFIXES`).
   De engine-brug blijft wel login-vereist.
3. **SessionMiddleware** — sessiecookie `zegt_sessie` (hierin `gebruiker_id` en `rol`).

De `lifespan` van `main.py` start naast de TTS-warmup en de onderhoudstaak ook
de anonieme achtergrondmeting voor de publieke snelheidspagina
(`routers/snelheid.py:monitor_lus`), zolang `SNELHEID_MONITOR_AAN` aan staat.

## Modules (root)

- `main.py` — app-entry, middleware, statische serving, SPA-fallback.
- `config.py` — leest `.env` en exporteert alle instellingen. Alleen
  `SECRET_KEY` is verplicht; `AZURE_SPEECH_KEY`/`AZURE_SPEECH_REGION` zijn
  optioneel (Azure is vangnet — zonder sleutels blijft de app draaien op de
  eigen engine en Piper). Azure voor de uitspraak is daarnaast een opt-in per
  leerling (`azure_modus`, standaard `niets`) en nooit beschikbaar voor
  docent-accounts, accounts onder de 16 of gastleerlingen.
- `database.py` — `create_engine`, `SessionLocal`, `Base`, `init_db()` en
  `_migreer()` (ad-hoc migraties, geen Alembic).
- `models.py` — alle SQLAlchemy-modellen.
- `srs.py` — het herhaalsysteem ("de ladder"); pure logica zonder DB-IO.
- `azure_speech.py` — Azure Pronunciation Assessment (vangnet, alleen opt-in per leerling) + `spreek_uit` (TTS-vangnet).
- `lokale_engine.py` — client die audio naar de eigen engine stuurt; herkansing
  bij overbelasting (`EngineBezet`) zonder naar Azure terug te vallen. Kan in de
  offline bundel tijdelijk de online engine-brug gebruiken (`sta_brug_toe`).
- `lokale_tts.py` — Piper-TTS (lokaal, primair) met Azure als vangnet; in de
  offline bundel zonder lokale TTS-dienst eerst de online brug.
- `uitspraak_status.py` — de beheerdersschakelaar "uitspraakserver uit"
  (`AppInstelling.uitspraak_uit`): cachet de stand 5 seconden per proces en
  levert de vaste site-melding. Staat hij uit, dan slaan de engine- en
  TTS-clients de hosts op de engine-host meteen over (geen timeouts).
- `lokale_ai.py` — fastText-taaldetectie (lokaal).
- `mistral_client.py` — Mistral-aanroepen (foto→woorden/dialoog, vertalen,
  sorteren, gesprek).
- `azure_content_safety.py` — afbeeldingsmoderatie.
- `rate_limit.py` — sliding-window rate limiter; Redis bij `REDIS_URL`, anders
  in-memory per proces.
- `auth_utils.py` — scrypt-wachtwoordhashing en rolchecks (`vereis_docent`).
- `mail.py` — SMTP-verzending (bewust no-op zonder config).
- `schemas.py` — Pydantic request/response-modellen.

## Routers (`routers/`)

| Router | Prefix | Verantwoordelijkheid |
|--------|--------|----------------------|
| `auth.py` | `/api/auth` | registreren, inloggen, OAuth (Google/GitHub), e-mailverificatie, wachtwoord-reset, CSRF-token, `/me` |
| `ai.py` | `/api/ai` | foto→woordparen of dialoogregels, batchvertalen naar NL, taaldetectie, woorden sorteren |
| `woorden.py` | `/api/woordenlijsten` | sets CRUD, woorden, importeren, deellinks (ook met vaste oefenmodus), thumbnails |
| `leerlingen.py` | `/api/leerlingen` | leerlingbeheer: lijst, detail en verwijderen (beheerder) |
| `oefenen.py` | `/api/oefenen` | de oefenflow: `volgende`, spreken (incl. strenge uitspraakmodus)/spellen/meerkeuze/flitskaarten, TTS, geschiedenis |
| `klassen.py` | `/api/klassen` | klassen en koppelingen (docent) |
| `dashboard.py` | `/api/dashboard` | docentendashboard en voortgang; leerlingdossier en docent-aanvragen goedkeuren (beheerder) |
| `demo.py` | `/api/demo` | publiek zonder login: demo op de thuispagina (uitspraakscore + TTS) en de spraaktest `/praat` (asynchrone diagnose tegen verwarwoorden + oogst van trainingsopnames) |
| `delen.py` | `/api/delen` | deellinks claimen (ook als gast) |
| `instellingen.py` | `/api/instellingen` | naam/e-mail wijzigen, Azure-modus, docent-aanvragen, account verwijderen |
| `engine_brug.py` | `/api/engine` | online engine-brug voor de offline desktop-app: uitspraak en TTS zolang het lokale model laadt (stateless, login vereist) |
| `mobiel_offline.py` | `/api/offline` | model-manifest, vocabulaire, VAD-model en scoringsconstanten voor de native Android-app (altijd geladen, ook online) |
| `offline_bundel.py` | `/api/woordenlijsten` (bundel) | set-bundel voor de native Android-app: manifest + losse referentiebestanden (altijd geladen, ook online) |
| `gesprek.py` | `/api/gesprek` | gespreksmodus (testfase): ontwerpen van de systeemprompt (Mistral/Pixtral, beheerder-only), gesprekssessie starten, spraak-/tekstbeurten, hints en afronden |
| `rollenspel.py` | `/api/rollenspel` | rollenspel als setsoort: scenario ophalen (eigen/openbaar/deelname) en één leerling-beurt hardop beoordelen via de lokale engine (altijd geladen, login vereist) |
| `snelheid.py` | `/api/snelheid` | publieke snelheidspagina en engine-stresstest: actuele status, begrensde stressrun en live voortgang (anoniem, geen login) |
| `status.py` | `/api/status` | publieke systeemstatus zonder login (geeft nooit 5xx): staat de uitspraakserver uit, inclusief de vaste site-melding |

## Frontend (`static/`)

- `index.html` — de SPA-shell (alle "pagina's" zijn views binnen één bestand).
- `js/api.js` — API-client (fetch + CSRF-header).
- `js/app.js` — router/state van de SPA, plus `SysteemMelding`: de sitebrede
  melding bovenaan als de beheerder de uitspraakserver heeft uitgezet (publiek
  `GET /api/status`, opgehaald bij het opstarten — ook vóór login — en elke
  2 min; in de offline/desktopmodus bewust inactief).
- `js/student.js` — de oefenflow (spreken — inclusief de strenge
  uitspraakmodus — meerkeuze, flitskaarten, typen).
- `js/recorder.js` — microfoonopname via MediaRecorder → PCM/WAV in de browser.
- `js/maken.js` — set-editor (maken/importeren/bewerken), inclusief de kaart
  "Gesprek ontwerpen" (testfase, beheerder-only): een meerronde chat die samen
  met de docent de systeemprompt opstelt, met een knop "Stel definitieve prompt
  voor" (`finaliseer`).
- `js/dashboard.js` — docentendashboard; in de desktopapp ook de sectie "Sets
  op zeg-t.nl" (online sets met downloadknop, gevuld via
  `GET /api/offline/online/sets`) en het "Lokaal"-label op lokale setkaarten.
- `js/delen.js` — deellink-claimflow en de openbare set-pagina (`/sets/{slug}`).
  De startknop ("Oefenen", of "Start rollenspel" bij een rollenspel) opent een
  modus-blad met Slim, Uitspraak, Flitskaarten, Spreken, Typen, Meerkeuze en —
  als de gespreksmodus aan staat én de set een gespreksinstructie heeft
  (`heeft_draaiboek` via `GET /api/gesprek/status?set=`) — Gesprek. Hetzelfde
  blad (`_openOefenBlad`, met
  optionele `startFn`) wordt door `js/dashboard.js` gebruikt voor de
  "Oefenen"-knop op een setkaart in "Mijn sets". Hangt er een vaste oefenmodus
  aan de deellink, dan start de knop direct in die modus (geen keuzemenu).
- `js/rollenspel.js` — de rollenspelweergave op de route `/rollenspel/{slug}`
  (view `#view-rollenspel`): vaste beurten, eigen beurten hardop beoordelen via
  `POST /api/rollenspel/…`, tegenstander-beurten voorlezen via TTS.
- `js/gesprek.js` — de gespreksmodus (testfase): het handsfree gesprek zelf op
  de route `/gesprek/{slug}` (een ChatGPT-achtige chat met de volledige
  geschiedenis, VAD-opname via `Recorder.startAuto`, tekstinvoer als fallback,
  een hint-knop en een "Gesprek beëindigen"-knop; AI-zinnen worden via TTS
  voorgelezen). Het ontwerpen van de systeemprompt zit in `js/maken.js`. De
  Gesprek-optie in het oefenmodus-blad op het setdetail staat in `js/delen.js`,
  de systeemschakelaar in `js/admin.js`/`index.html`.
- `js/desktop.js` — desktophulp (WebView2): `isDesktop`-detectie, de
  accountstatusbalk (inlogstatus via `GET /api/offline/online/status`, met
  "Inloggen"-knop naar `zegt://inloggen`), de "Offline"-downloadknop op de
  online setkaarten van de sectie "Sets op zeg-t.nl" (door `js/dashboard.js`
  gerenderd), voortgangsmodal voor `zegt://download-set/…` en de "Wil je de
  taal downloaden?"-vraag vóór het oefenen. Host→SPA-protocol
  (via `chrome.webview`-berichten): `{"soort":"set-download",…}` (downloadvoortgang,
  fases `voorbereiden`/`setzip`/`model`/`import`/`engine`/`klaar`, met bij een
  fout `fase:"fout"`), `{"soort":"desktop-status","modellen":{taal:bool},
  "sets":[…],"online":bool}` (per taal of het STT-model aanwezig is, de lokaal
  gedownloade set-slugs en de autoritatieve online-status, verstuurd na elke
  voltooide navigatie en op verzoek) en
  `{"soort":"modelgrootte",…}` (de downloadgrootte van een ontbrekend taalmodel
  uit het modelmanifest). De SPA bepaalt online/offline aan de hand van het
  `online`-veld in `desktop-status` — de host-probe `LokaleStack.IsOnlineAsync`
  doet drie korte pogingen voordat hij offline meldt — en dus niet op
  `navigator.onLine`. De Windows-host opent de WebView altijd op de lokale
  webapp (`http://127.0.0.1:8477/sets`); zeg-t.nl is alleen nog sync-partner
  (online-status, setlijst en voortgangssync via `/api/offline/online/*` op de
  achtergrond) en de online-login loopt via de systeembrowser. Het
  `online`-veld zegt dus of die online-sync mogelijk is. Alleen zonder internet
  start een volledig lokale set direct via
  `zegt://oefen-lokaal/{slug}`, en uitsluitend als set, TTS-audio en taalmodel al
  aanwezig zijn; ontbreekt er iets, dan volgt een Nederlandse melding zonder
  online-knop. Ontbreekt er met internet iets, dan toont de SPA de vraag met de
  groottes van set/TTS-audio (schatting uit
  `GET /api/woordenlijsten/{id}/offline-info`), het ontbrekende taalmodel en het
  totaal. Bij "Ja, downloaden" navigeert de SPA naar
  `zegt://download-set/{id}?taal=…&slug=…&start=1` (de doeltaal gaat als
  tweelettercode mee, bv. `en`; de exacte, hoofdlettergevoelige slug blijft
  behouden); de host downloadt de set en eventueel het taalmodel en opent daarna
  **zelf** de lokale webapp op `/oefenen/{slug}` (geen SPA-eigen auto-start). In
  de gewone browser inactief.
- `js/icons.js`, `js/confetti.js` — hulpmiddelen.
- `css/stijl.css` — alle styling.
- `home.html`, `privacy.html`, `tos.html`, `leraren.html`, `helpmee.html`,
  `snelheid.html`, `praat.html` (+ `js/praat.js`) — losstaande publieke
  pagina's; `praat.html` is de spraaktest (zie de flow hieronder).
- `llms.txt` + `llms-full.txt` + `llm/*.md` — publieke LLM-documentatie (via de
  catch-all geserveerd als `/llms.txt`, `/llms-full.txt` en `/llm/…`).
- `static/uploads/` — thumbnails (gemount op `/uploads`).

## Belangrijke flows

- **Oefenen:** frontend vraagt `GET /api/oefenen/volgende`, krijgt een woord +
  modus (slim: flitskaarten → meerkeuze → spreken/typen), stuurt de opname naar
  `POST /api/oefenen/spreken/{id}`, de server beoordeelt (lokaal leidend, Azure
  als opt-in vangnet), werkt de ladder bij (`srs.py`) en geeft feedback terug.
- **Importeren:** `POST /api/woordenlijsten/importeren` (plaktekst, één paar per
  regel) of `POST /api/ai/foto` (één foto van een woordenlijst of dialoog,
  letterlijk via Mistral).
- **Delen:** docent maakt een deellink (`/delen/{token}`); een leerling claimt
  hem en krijgt een `LijstDeelname` (of een gastaccount).
- **Snelheid (publiek):** de losstaande pagina `/snelheid` haalt
  `GET /api/snelheid/status` op en laat een bezoeker een begrensde stresstest
  starten. `POST /api/snelheid/stress` (CSRF-plichtig, 5/10 min per IP, één run
  tegelijk) stuurt een door de TTS-dienst gesynthetiseerde oefenzin een aantal
  keer naar de eigen engine; `GET /api/snelheid/stress/{run_id}` toont de live
  voortgang. Alleen anonieme tijdmetingen en statuscodes worden bewaard.
- **Spraaktest (publiek):** de losstaande pagina `/praat` (alias `/spraak`,
  `/spraaktest`, `/beoordelingstest`; geen login, `noindex`) laat bezoekers
  moeilijke woorden nazeggen. Na expliciet akkoord ("Ik ga akkoord") wordt de
  opname bewaard en op de achtergrond tegen het doelwoord én de verwarwoorden
  beoordeeld (`POST /api/demo/diagnose`, asynchroon met job-id; bij
  engine-drukte gaat de job vanzelf verder). Alleen overtuigend herkende
  opnames (oordeel `goed`, score >= 85, `nauwkeurigheid_incl` >= 80) komen in
  de oogst/trainingsset (`data/praat-opnames/`); niets komt in de database.
  Zie `/llm/api.md`.
- **Uitspraakserver uit (beheer):** bij een storing of zonder internet op de
  engine-host kan de beheerder in Beheer → Kwaliteit & status de
  uitspraakserver (engine + TTS-dienst op de engine-host) in één klik uitzetten
  (`AppInstelling.uitspraak_uit` via `GET/POST /api/admin/uitspraak`;
  `uitspraak_status.py` cachet de stand 5 s per proces). Staat hij uit, dan
  slaan `lokale_engine.py`/`lokale_tts.py` die hosts meteen over — geen
  wachttijd op timeouts: beoordelen/transcriberen keren direct af, voorlezen
  valt meteen terug op het Azure-vangnet en spreken geeft zonder dat vangnet
  503 met een eigen Nederlandse melding. Bezoekers zien bovenaan de site een korte melding
  (`#systeem-melding`, `SysteemMelding` in `js/app.js`, via het publieke
  `GET /api/status`) die verdwijnt zodra de schakelaar weer aan staat. Zie
  `/llm/uitspraak-engine.md`.
- **Gesprek (testfase):** de beheerder zet in de beheer-UI de DB-schakelaar
  `app_instellingen.gesprek_aan` aan (`GET/POST /api/admin/gesprek`). Daarna
  verschijnt in het oefenmodus-blad op het setdetail de Gesprek-optie (alleen als
  `actief` en de gebruiker is ingelogd). De systeemprompt ontwerpt de beheerder
  in het **bewerkscherm** van de set (kaart "Gesprek ontwerpen", `js/maken.js`) via een
  **meerronde Mistral-chat** (`POST /api/gesprek/ontwerp`, optioneel met een
  screenshot via Pixtral); de assistent praat in de brontaal en werkt samen met
  de docent aan de prompt, tot de docent afrondt (of op "Stel definitieve
  prompt voor" klikt). De prompt én een JSON-snapshot van alle setwoorden worden
  opgeslagen. Daarna start de sessie (`POST /api/gesprek/sessie`); de AI zegt de
  eerste zin (via TTS, `POST /api/oefenen/tts`) en de leerling antwoordt
  **handsfree gesproken** (`POST /api/gesprek/sessie/{id}/spraak`, open
  transcriptie via de eigen engine) of getypt
  (`POST /api/gesprek/sessie/{id}/bericht`). De leerling kan een hint opvragen
  en het gesprek beëindigen (`POST /api/gesprek/sessie/{id}/afronden`), waarna de
  gebruikte én niet-gebruikte setwoorden terugkomen.
- **Offline/desktop (hybride):** de lokale webapp start de uitspraak-engine lui.
  Zolang een taalmodel nog laadt, gebruikt hij de online engine-brug
  (`/api/engine/*`) met de door de desktop-app doorgegeven inlogsessie; zodra het
  model klaar is, beoordeelt hij weer volledig lokaal. Een klik op een setkaart
  gaat naar de setdetailpagina (`/sets/{slug}`, via `GET /api/woordenlijsten/
  by-slug/{slug}`); oefenen start alleen via de expliciete startknop "Oefenen" of
  een directe `/oefenen/…`-URL. De Windows-host (`zegt_desktop_host/MainForm.cs`)
  heeft geen bovenste menubalk meer en start **local-first**: hij brengt altijd
  de lokale stack op en navigeert naar `http://127.0.0.1:8477/sets`, ook met
  internet; zeg-t.nl is alleen nog sync-partner (online-status, setlijst en
  voortgangssync op de achtergrond). Lukt de lokale start niet, dan toont hij
  `start.html` met "Opnieuw proberen". Mist het taalmodel van de doeltaal (of de
  set/TTS-audio), dan vraagt de SPA eerst of die gedownload moeten worden
  (`zegt://download-set/…` met `start=1`); alleen zonder internet en alleen als
  set, TTS-audio en taalmodel al aanwezig zijn, start de host direct lokaal via
  `zegt://oefen-lokaal/{slug}`. Na de download opent de host zelf de lokale
  oefenomgeving. Voor delta-updates van sets vergelijkt de host de
  online `inhouds_hash` (`GET /api/woordenlijsten`) met de vastgelegde versie uit
  `GET /api/offline/set-versies` (tabel `offline_set_versies`); verandert de
  hash, dan wordt de set opnieuw gedownload.
- **Mobiel-offline (native Android):** de app haalt eerst het model-manifest
  (`GET /api/offline/modellen`), de vocabulaire (`/modellen/vocab`), het
  VAD-model (`/modellen/vad`) en de scoringsconstanten (`GET /api/offline/scoring`)
  op, plus per set het bundelmanifest
  (`GET /api/woordenlijsten/{id}/offline-bundel`). Deze model-/scoringsendpoints
  zijn publiek vóór login (onboarding); de set-bundel vereist een login zonder
  gastaccount. De zware modellen komen van HuggingFace via
  `GET /api/offline/modellen/{code}` (302-redirect; HF ondersteunt `Range`), de
  VAD levert de VPS zelf met `Range`/`ETag`/304. Dat bundelmanifest noemt per woord
  de referentiepaden (`ref/{id}-{variant}.{ext}`) en de meerkeuze-opties; de app
  downloadt alleen wat hij nog mist via
  `/api/woordenlijsten/{id}/offline-bundel/bestand?pad=…` (met `Range`, dus
  hervatbaar, en `ETag`/304). De webapp bouwt ontbrekende referenties via de engine
  (`UITSPRAAK_ENGINE_URL`) en cachet ze op de VPS onder
  `MOBIEL_BUNDEL_DIR/bundels/<sleutel>`; de sleutel is een hash van
  referentiebackend (uit de engine, bijv. `v4-onnx:gtts:com`) + doeltaal +
  setinhoud, zodat een ongewijzigde set niet opnieuw wordt gebouwd.
  Deze routers draaien altijd mee (ook online); de desktop-semantiek
  (`routers/offline.py`, alleen bij `ZEGT_OFFLINE` + loopback) blijft ongewijzigd.
