# Herhaalsysteem — "de ladder"

Implementatie: `srs.py` (pure logica) + `routers/oefenen.py` (toepassing en
sessies). Gebaseerd op Leitner, Pimsleur, SM-2/SuperMemo en Cepeda et al.

## De treden

| Trede | Naam | Interval |
|-------|------|----------|
| 0 | Nieuw | binnen de sessie (2-3×) |
| 1 | Morgen | 1 dag |
| 2 | 3 dagen | 3 dagen |
| 3 | Week | 7 dagen |
| 4 | 2 weken | 14 dagen |
| 5 | Check | 30 dagen → spellingscheck |
| top | Beheerst | controle elke 30 dagen |

`NIVEAU_INTERVALLEN` en `MASTER_INTERVAL` staan bovenaan `srs.py`.

## Wat er na een poging gebeurt

Op basis van de score (0-100) na een spreekpoging:

- **fout of ≤ 55** → het woord *blijft* op zijn trede en komt in deze sessie
  nog **2×** terug. Er is **nooit een reset naar beneden**; een fout kost alleen
  extra herhalingen.
- **55-80** → telt als goed; nog **2×** terug in deze sessie.
- **> 80** → telt als goed; nog **1×** terug in deze sessie.
- Elke goede herhaling telt 1 van "nog n×" af; bij 0 over gaat het woord een
  trede omhoog (of wordt het beheerst na de spellingscheck).

De kern zit in `verwerk_spreek_resultaat` (en de varianten
`verwerk_spel_resultaat` / `verwerk_meerkeuze_resultaat`).

## Toetsvormen

- **Spreken** — de uitspraakscore van de engine (0-100); een verkeerd woord is
  altijd fout, ook bij een mooie uitspraak.
- **Meerkeuze** — binair: goed = 100, fout = 0. Anti-gokken: meerdere antwoorden
  sneller dan `GOK_DREMPEL_MS` (1 s) binnen een venster tellen als gokken/fout.
- **Flitskaarten** — kennismaking zonder oordeel (elke kaart 3×, willekeurig);
  de ladder blijft onaangeroerd.
- **Typen (zelfstandige modus)** — de leerling typt elk beschikbaar woord,
  ook nieuwe en niet-moeilijke woorden. Een gewone typbeurt volgt dezelfde
  ladderregels als spreken: een goed antwoord is een goede poging (score 100),
  een fout antwoord laat het woord op zijn trede en geeft nog 2 herhalingen in
  de sessie. Is een woord beheerst of op de hoogste trede zonder open
  herhalingen, dan gebruikt de server ook in deze modus de aparte
  spellingscheckafhandeling.
- **Typvraag in Slim / spellingscheck** — de typvraag in de slimme route blijft
  alleen voor moeilijke woorden, na de flitskaarten en meerkeuze. Bovenaan de
  ladder (trede 5 of beheerst, als er geen open herhalingen zijn) gebruikt de
  slimme route voor moeilijke woorden de aparte spellingscheck: goed gespeld =
  beheerst, fout = op dezelfde trede/beheerst blijven + nog 2× in de sessie.

## De "slimme" route

De standaardmodus (`PRAKTIJK_MODI` in `oefenen.py`): elk woord doorloopt
dezelfde stappen, in willekeurige volgorde binnen elke stap:

```text
flitskaarten (3×) → meerkeuze → spreken (of spellen voor moeilijke woorden)
```

De logica staat in `_slim_modus` en `_slim_stap`. Concreet, per woord:

1. Zolang het woord nog niet `FLITSEN_PER_WOORD` (= 3) keer als flitskaart is
   voorbijgekomen (`flips < 3`), is de modus **flitskaarten**.
2. Daarna volgt **meerkeuze** (als de laatste modus geen of flitskaarten was).
3. Na de meerkeuze volgt **spreken** — of **spellen** als het woord door de
   docent als moeilijk is gemarkeerd (`_spreek_of_typ`). Dit is de slimme
   typvraag; de zelfstandige Typen-modus is een aparte vaste oefenvorm.
4. Na een spreek-/typpoging:
   - Heeft het woord nog openstaande herhalingen (`herhalingen_nog > 0`), dan
     blijft het in diezelfde vorm.
   - Is het een **zwak** spreekwoord (laatste 3 spreekscores gemiddeld < 55),
     dan krijgt het **weer een meerkeuze-stap** voordat het opnieuw mag spreken.
   - Anders weer spreken (of typen voor moeilijke woorden).

### Hoe zwakke woorden worden herkend

`_zwakke_woorden`: per woord worden de spreekpogingen met een score gesorteerd
op tijd (nieuwste eerst) en wordt het gemiddelde van de **laatste 3** genomen.
Is dat gemiddelde `< 55`, dan geldt het woord als zwak en krijgt het in de
slimme route opnieuw een meerkeuze-stap als opstapje.

### Hoe "moeilijke" woorden verschillen

Een door de docent als `moeilijk` gemarkeerd woord:

- krijgt in de slimme route een **typvraag** in plaats van (alleen) spreken;
- krijgt **bovenaan de ladder** (trede 5) en bij de maandelijkse controle een
  **verplichte spellingscheck**. Spreken en spellen zijn twee verschillende
  geheugensporen; pas als beide lukken is zo'n woord echt beheerst. Dit zijn
  beperkingen van de slimme route; de zelfstandige Typen-modus laat ook andere
  woorden typen, met de gewone ladderafhandeling.

### Randomisatie en interleaving

- Binnen elke stap is de volgorde willekeurig (`random.shuffle` + stabiele sort).
- Bij flitskaarten gaan de woorden die het minst vaak zijn getoond eerst, zodat
  de 3 rondes door elkaar lopen.
- Een woord dat < 90 s geleden is geoefend komt pas weer aan de beurt als er
  niets anders is — herhalingen komen dichtbij, maar nooit direct achter elkaar.

### Voorbeeld: een nieuw woord in de slimme modus

```text
Nieuw woord (trede 0), niet "moeilijk":
  beurt 1-3   → flitskaart (geen oordeel, ladder beweegt niet)
  beurt 4     → meerkeuze-vraag
                 - goed (100)  → nog 1× in deze sessie
                 - fout (0)    → blijft trede 0, nog 2× in deze sessie
  beurt 5+    → spreken
                 - score > 80  → nog 1×, daarna trede omhoog (naar trede 1 = "morgen")
                 - score 55-80 → nog 2×, daarna trede omhoog
                 - score ≤ 55 of fout → blijft trede 0, nog 2×
```

Dus: een nieuw woord kan eerst **drie keer als flitskaart** verschijnen, daarna
**meerkeuze** krijgen en vervolgens via **spreken** worden beoordeeld — precies
zoals hierboven geschetst. (Dat voorbeeld klopt met de code.)

## Voorbeelden: de ladder

### Voorbeeld: fout

```text
Trede: 2
Score: 43   (of: verkeerd woord gezegd)
→ fout
→ blijft op trede 2
→ komt in deze sessie nog 2× terug
```

### Voorbeeld: goede uitspraak

```text
Trede: 2
Score: 87
→ goed (boven de 80)
→ nog 1× in deze sessie
→ is die herhaling ook goed, dan: trede 2 → trede 3 ("week", 7 dagen)
```

```text
Trede: 2
Score: 61
→ goed (55-80)
→ nog 2× in deze sessie
→ na twee goede herhalingen: trede 2 → trede 3
```

### Voorbeeld: beheerst (top bereiken)

```text
Trede 5 ("Check"), niet "moeilijk":
  → spreken, score > 80 → nog 1× → goed → "beheerst"
  (in hands-free modus telt de voltooide spreekreeks ook als beheerst)

Trede 5 ("Check"), wél "moeilijk":
  → spellingscheck
      - goed gespeld → "beheerst" (controle over 30 dagen)
      - fout gespeld → blijft op trede 5, nog 2× in deze sessie

Beheerst woord, maandelijkse controle:
  → goed → blijft beheerst (volgende controle over 30 dagen)
  → fout → blijft beheerst, nog 2× in deze sessie
```

## Sessies en batches

- Een sessie is een aaneengesloten oefenblok; een pauze langer dan `SESSIE_GAP`
  (45 min) sluit hem af (`Oefensessie`).
- Woorden die binnen een sessie een trede omhoog gaan, krijgen bij het afsluiten
  `volgende_herhaling = sessie-einde + interval van hun trede`. Zo komen alle
  woorden van één ronde tegelijk terug ("batch"), in plaats van elk op hun eigen
  voltooiingstijd. Zie `_sluit_sessie` / `_koppel_sessie`.

## Selectie van het volgende woord

`GET /api/oefenen/volgende` kiest het volgende woord (`_kies_willekeurig_woord`)
met prioriteitsgroepen:

0. openstaande sessie-herhalingen eerst,
1. dan woorden die volgens schema aan de beurt zijn (of over tijd),
2. dan nieuwe woorden,
3. woorden die < 90 s geleden zijn geoefend (`RECENT_GEDURENDE`) als laatste.

Binnen een groep is de volgorde willekeurig (interleaving). In de slimme modus
geldt daar nog een stap-volgorde bovenop (alle flitskaarten vóór de
meerkeuze-vragen vóór het spreken/typen). Als niets aan de beurt is, volgt een
pauzescherm (`_wacht_antwoord`) met de wachttijd tot het eerstvolgende woord;
"Toch doorgaan" (`modus=doorgaan`) negeert het schema één ronde. Daarnaast kan
de client de wachttijd ook los omzeilen met `?toch=true` op `GET /volgende`:
dan wordt de hele set beschikbaar gesteld (zelfde keuzelogica, open
sessie-herhalingen gaan vóór) en komt er altijd een gewoon woord terug in de
gekozen oefenvorm, nooit het pauzescherm.

Voor de routebepaling haalt `GET /api/oefenen/volgende` de
flitskaarttellingen, de laatst gebruikte modus en de recente spreek-scores voor
alle kandidaatwoorden in bulk op. Daardoor zijn dit geen afzonderlijke
databasequeries per kandidaat.
