# Scormia

Outil de création de modules e-learning au format SCORM 1.2, intégré à Bubul.
Un module est composé d'un sommaire d'écrans (arborescence 2 niveaux) ; chaque
écran contient une pile verticale de blocs. L'export produit un ZIP autonome
importable dans Moodle ou tout LMS SCORM 1.2.

## Architecture

```
Entités DB
  ScormiaModule  ← 1..N  ScormiaScreen (arbre parent/enfants)  ← 1..N  ScormiaBlock
       │                 (nodeType, depth, position)                   (type, position, content, style)
       │
       │  ScormiaPersistenceService (moduleToJson / applyJsonToModule)
       ▼
  JSON de transport  ──────────────────────────────────────────────────────┐
  { uuid, title, accent, passingScore, screens: [{ title, nodeType,      │
    depth, blocks: [{type, content, style}] }] }                          │
       │                                                                    │
       │  (éditeur JS)                                                      │  (export PHP)
       ▼                                                                    ▼
  BlockTypeRegistry  ←  services taggés scormia.block_type        ScormPackageExporter
  (validation / normalisation)                                      │
       │                                                            ├── resolvePlayerFiles()
       │  assets/scormia/blocks/render.js                          │     (entrypoints.json)
       ▼                                                            ├── collectMedia()
  ELEMENTS (25 types — blocks/elements/{type}.js)                  ├── buildIndexHtml()
  heading, subtitle, richtext, image, video, audio, embed,         │     (thème CSS inline + JSON module inline)
  list, callout, quote, divider, button, stats, table,             └── imsmanifest.xml
  code, spacer, flashcards, accordion, timeline, hotspot,
  dragdrop, quiz, match, fill, order
       │
       ├── Éditeur (canvas.js + outline.js)
       └── Player partagé (player.js)                                   (ImsManifestGenerator)
               │
               └── ScormApi (scorm_api.js) — tracking SCORM 1.2
                   + fausse API aperçu
```

### Diagramme des dossiers

```
src/
├── Entity/Scormia/
│   ├── ScormiaModule.php          # Module (uuid, title, accent, passingScore)
│   ├── ScormiaScreen.php          # Nœud du sommaire (title, nodeType, position, parent)
│   ├── ScormiaBlock.php           # Bloc (type, position, content JSON, style JSON)
│   └── ScormiaUserTheme.php       # Couleur d'accent perso enregistrée par l'utilisateur
│
├── Service/Scormia/
│   ├── ScormiaPersistenceService.php   # Conversion module ↔ JSON
│   ├── Block/
│   │   ├── BlockTypeInterface.php      # Contrat : key/label/defaults/validate/normalize
│   │   ├── BlockTypeRegistry.php       # Auto-découverte via tag scormia.block_type
│   │   ├── BlockStyle.php              # Mise en forme d'un bloc (whitelist stricte, normalize/validate)
│   │   ├── HeadingBlock.php
│   │   ├── RichTextBlock.php
│   │   ├── ImageBlock.php
│   │   ├── VideoBlock.php
│   │   ├── ListBlock.php
│   │   ├── CalloutBlock.php
│   │   ├── QuoteBlock.php
│   │   ├── DividerBlock.php
│   │   └── HtmlCleaner.php
│   └── Export/
│       ├── ScormPackageExporter.php    # Point d'entrée export ZIP
│       ├── ImsManifestGenerator.php    # Génère imsmanifest.xml SCORM 1.2
│       └── ScormExportException.php
│
└── Controller/Scormia/
    ├── ScormiaController.php       # Bibliothèque + CRUD (list/create/delete)
    └── ScormiaEditorController.php # Éditeur (save/preview/export/upload/user-themes)

assets/
├── controllers/scormia/
│   ├── library_controller.js       # Stimulus : liste des modules (bibliothèque)
│   ├── editor_controller.js        # Stimulus : éditeur principal (shell 3 zones)
│   ├── preview_controller.js       # Stimulus : vignette d'aperçu miniaturisée (bibliothèque)
│   ├── configure_controller.js     # Stimulus : page de configuration (manuel / PDF / IA)
│   └── lib/
│       ├── outline.js              # Zone gauche : sommaire arbre 3 niveaux
│       ├── canvas.js               # Zone centrale : carte + toolbar flottante
│       ├── canvas/block-ops.js     # Mutations de la pile de blocs (feuille sans dépendances)
│       ├── picker.js               # Overlay « Ajouter un bloc » (recherche + chips catégories)
│       ├── media_modal.js          # Modale média onglets (Images/Vidéos/Audios du cours, Pixia, Monalisia, Castia, URL, Import)
│       ├── theme_panel.js          # Panneau Thème (overlay 2 volets)
│       ├── theme_css.js            # Miroir JS de ScormiaThemes::buildCss
│       └── icons.js                # SVG utilitaires de l'éditeur
│
└── scormia/
    ├── scormia.css                 # Styles globaux outil (variables --acc + blocs)
    ├── tokens.css                  # Design tokens (13 neutres + 3 font stacks)
    ├── fonts.css                   # Imports woff2 Poppins + Fraunces
    ├── blocks/
    │   ├── render.js               # renderBlock/renderScreen — délègue aux éléments
    │   ├── interactions.js         # initInteractions — dispatch vers les init des éléments
    │   ├── media_common.js         # Mobilier partagé des 4 médias : placeholder, wireMediaPlaceholder, mediaToolbarExtra
    │   ├── inline_edit.js          # editable/trackEmpty — édition en place partagée entre éléments
    │   ├── elements/               # UN fichier par élément : render/init/sample
    │   │   ├── index.js            # Registre ELEMENTS (imports explicites)
    │   │   └── heading.js … order.js
    │   ├── math.js                 # Rendu LaTeX (KaTeX)
    │   ├── hljs.js                 # Coloration syntaxique (highlight.js, 30 langages)
    │   └── embed.js                # Conversion URL → iframe YouTube/Vimeo
    └── player/
        ├── player_entry.js         # Point d'entrée Encore scormia_player
        ├── player.js               # Navigation, progression, tracking complétion
        ├── player.css              # Styles du player
        ├── scorm_api.js            # Wrapper SCORM 1.2 + fausse API aperçu
        ├── shuffle.js              # PRNG mulberry32 + Fisher-Yates + drawSeed (algorithme FIGÉ)
        └── suspend.js              # Sérialisation/restauration suspend_data (paliers dégradation)
```

## Thème mono-accent (design « CODE V1 »)

### Modèle de données

`ScormiaModule` porte une seule couleur `accent` (hex `#RGB`–`#RRGGBBAA`, défaut `#3b5bdb`).
Une couleur suffit à thémer tout le module : 6 dérivés sont calculés à la volée en CSS via `color-mix`.

| Variable CSS | Calcul |
|---|---|
| `--acc` | couleur brute |
| `--acc-soft` | `color-mix(in srgb, acc 13%, #fff)` |
| `--acc-line` | `color-mix(in srgb, acc 26%, #fff)` |
| `--acc-border` | `color-mix(in srgb, acc 32%, #fff)` |
| `--acc-mid` | `color-mix(in srgb, acc 55%, #fff)` |
| `--acc-deep` | `color-mix(in srgb, acc 82%, #1b2440)` |
| `--acc-bg` | `color-mix(in srgb, acc 5%, #eef1f6)` |

Le bloc `:root{…}` est émis par `ScormiaThemes::buildCss(accent)` (PHP) et son **miroir exact** `buildThemeCss(accent)` (JS — `assets/controllers/scormia/lib/theme_css.js`). Toute évolution des formules doit être répercutée dans les deux fichiers.

### Presets

`ScormiaThemes::PRESETS` (PHP) et `PRESETS` (JS, `theme_css.js`) listent les mêmes 12 couleurs nommées. Ajouter/retirer un preset = modifier les deux constantes en miroir.

### Couleurs personnalisées — `ScormiaUserTheme`

Entité `scormia_user_theme` (migration `Version20260706100000`) : une ligne par couleur sauvegardée par l'utilisateur. Contrainte d'unicité `(user_id, color)`. Partagée entre tous les modules d'un utilisateur.

Endpoints gérés par `ScormiaEditorController` :
- `GET /scormia/user-themes` → liste des couleurs de l'utilisateur courant.
- `POST /scormia/user-themes` → enregistre une nouvelle couleur (dédoublonnée par la contrainte unique).
- `DELETE /scormia/user-themes/{id}` → supprime une couleur (seul le propriétaire peut supprimer).

## Modèle de données — pile verticale

### Entités

`ScormiaScreen` — nœud du sommaire :

| Champ | Type | Rôle |
|---|---|---|
| `nodeType` | `cover \| chapter \| subtopic \| completion` | Type de nœud dans le sommaire |
| `parent` | auto-ref nullable | Lien parent/enfant pour les sous-chapitres (`depth = 1`) |
| `position` | int | Ordre parmi les nœuds de même niveau |

`ScormiaBlock` — un élément de la pile :

| Champ | Type | Rôle |
|---|---|---|
| `type` | string | Clé du `BlockTypeRegistry` (heading, richtext, image…) |
| `position` | int | Ordre dans la pile de l'écran |
| `content` | JSON | Données métier du bloc (schéma par type) |
| `style` | JSON nullable | Mise en forme (marginTop/Bottom, paddingY/X, align, background) |

Pas de colonne de grille (`rowIndex`/`colIndex`/`colSpan`) — le contenu d'un écran est une **pile simple** (un bloc par position).

### Transport JSON

`ScormiaPersistenceService::moduleToJson()` émet :

```json
{
  "uuid": "…",
  "title": "Mon module",
  "accent": "#3b5bdb",
  "passingScore": 50,
  "screens": [
    {
      "title": "Couverture",
      "nodeType": "cover",
      "depth": 0,
      "blocks": [
        { "type": "heading", "content": { "text": "Titre", "level": 2 }, "style": {} }
      ]
    }
  ]
}
```

`applyJsonToModule()` reconstruit les écrans depuis ce JSON (rétro-compat : l'ancien format `screens[].rows` est aplati rangée→colonne→bloc dans l'ordre).

### Ordre canonique des blocs (clés de tracking)

`render.js` assigne les clés `s{si}b{bi}` à chaque bloc dans l'ordre de la liste `screen.blocks` (tableau plat, pas de rangées). Exemple : `s0b2` = écran 0, troisième bloc.

### Mise en forme d'un bloc — `ScormiaBlock.style` + `BlockStyle`

Schéma whitelisté par `App\Service\Scormia\Block\BlockStyle` :

| Clé | Valeur | Normalisation |
|---|---|---|
| `marginTop`, `marginBottom` | entier px | clamp 0–96 ; `0` = clé retirée |
| `paddingY`, `paddingX` | entier px | clamp 0–96 ; `0` = clé retirée |
| `align` | `left \| center \| right` | `left` (défaut) = clé retirée |
| `background` | couleur hex `#RGB` à `#RRGGBBAA` | invalide/vide = clé retirée |

`BlockStyle::normalize()` est le garde-fou ; `applyJsonToModule()` normalise à l'enregistrement.

## Éditeur CODE V1 — shell 3 zones POC

L'éditeur est un shell plein écran à 3 zones :

```
┌─ Zone gauche ─────┬─ Zone centrale (canvas) ─────────┬─ Toolbar flottante ─┐
│  Sommaire arbre   │  Fil d'Ariane + carte blanche     │  (sur bloc sélect.) │
│  (outline.js)     │  avec la PILE de blocs (canvas.js)│  Monter/Desc/Dup/Sup│
│  3 niveaux :      │  ce que l'auteur voit =            └─────────────────────┘
│  cover            │  ce que voit l'apprenant
│  chapter          │  (renderScreen → .sc-stack)
│    subtopic       │
│  completion       │  « + Ajouter un bloc » en fin
└───────────────────┘  ouvre le picker
```

**Zone gauche — Sommaire (`outline.js`)** :
- Arbre 3 niveaux dérivé de la liste plate `screens[]` via `nodeType`/`depth`.
- Interactions : clic = sélectionner · double-clic = renommer en place · menu « ⋯ » = Renommer / Ajouter une sous-partie / Supprimer (modale) · drag & drop = réordonner au même niveau (tranche nœud + descendants).
- Couverture et page de fin : renommage seulement (ni drag, ni sous-partie, ni suppression).

**Zone centrale — Canvas (`canvas.js`)** :
- Chaque bloc est enveloppé d'un `.blk-row` : sélection au clic (halo `--acc`).
- Toolbar flottante au-dessus du bloc sélectionné : Monter / Descendre / Dupliquer / Supprimer.
- Édition en place déléguée aux éléments (`wireInlineEdit` — heading et richtext éditables dans le canevas).
- `renderScreen()` partagé éditeur/player : pas de mode aperçu distinct.

**Panneau Thème (`theme_panel.js`)** :
- Overlay quasi plein écran à 2 volets : volet gauche (presets + couleurs perso) · volet droit (galerie live — écran de démo statique `inert`).
- Application de l'accent : `module.accent` + réécriture de `#sc-theme-css` via `buildThemeCss` → recolore tout sans re-rendu.
- Les couleurs perso sont chargées une fois (GET), sauvegardées (POST) ou retirées (DELETE) via les endpoints user-themes.

**Pas de panneau inspecteur** — les réglages de mise en forme d'un bloc (`style`) sont gérés directement dans la toolbar flottante (pas de panneau latéral droit d'inspection distinct).

## Types de blocs (25)

| Clé | Classe PHP | Catégorie picker | Variantes |
|---|---|---|---|
| `heading` | `HeadingBlock` | contenu | 4 |
| `subtitle` | `SubtitleBlock` | contenu | 3 |
| `richtext` | `RichTextBlock` | contenu | 2 |
| `list` | `ListBlock` | contenu | 3 |
| `callout` | `CalloutBlock` | contenu | 4 |
| `quote` | `QuoteBlock` | contenu | 3 |
| `divider` | `DividerBlock` | contenu | 3 |
| `button` | `ButtonBlock` | contenu | 3 |
| `stats` | `StatsBlock` | contenu | 3 |
| `table` | `TableBlock` | contenu | — |
| `code` | `CodeBlock` | contenu | — |
| `spacer` | `SpacerBlock` | contenu | — |
| `image` | `ImageBlock` | medias | 3 (Bord arrondi / Carte légende / Polaroïd) |
| `video` | `VideoBlock` | medias | 3 (Sombre / Vignette titre / Clair minimal) |
| `audio` | `AudioBlock` | medias | 3 (Barre compacte / Carte podcast / Ligne minimale) |
| `embed` | `EmbedBlock` | medias | 3 (Navigateur / Cadre simple / Étiquette lien) |
| `flashcards` | `FlashcardsBlock` | interactif | 2 (Grille / Large) |
| `accordion` | `AccordionBlock` | interactif | 3 (Bordé / Rempli / Minimal) |
| `timeline` | `TimelineBlock` | interactif | 3 (Frise horizontale / Frise verticale / Cartes numérotées) |
| `hotspot` | `HotspotImageBlock` | interactif | 3 (Image + points / Points + liste / Numéros seuls) |
| `dragdrop` | `DragDropBlock` | interactif | 3 (Colonnes / Cartes / Compact) |
| `quiz` | `QuizBlock` | interactif | 0 (pas de variantes — le « type » est par question) |
| `match` | `MatchBlock` | interactif | 3 (Deux colonnes / Cartes reliées / Compact) |
| `fill` | `FillBlock` | interactif | 0 (trous détectés dans le texte entre `[crochets]`) |
| `order` | `OrderBlock` | interactif | 3 (Étapes / Cartes / Compact) |

Tous les types exposent `category`/`description`/`variants` directement sur l'élément JS (depuis P4).
Le picker n'a plus de dictionnaire FALLBACK statique : repli défensif `'contenu'` uniquement
pour un éventuel type orphelin (ne doit pas arriver).

## Catégorie médias (P3 — CODE V1)

Les 4 blocs médias partagent une infrastructure commune (`blocks/media_common.js`) et une modale
de sélection (`lib/media_modal.js`). Le flux canonique est :

```
url absente → placeholder (carte inerte en player, bouton « Ajouter » en éditeur)
   → modale média (bouton toolbar « Média » ou clic placeholder)
   → choix (bibliothèque du cours / Bubul / URL / import)
   → content.url ← URL posée ; refresh ciblé du bloc ; autosave
   → normalize PHP (save) conserve url + variant
   → moduleToJson → player rend le bloc avec la même fonction render()
   → export : collectMedia() copie /uploads/scormia/modules/{uuid}/… dans assets/ du ZIP
              et réécrit les URLs en chemin relatif
```

### Schémas `content` (après normalisation PHP)

| Type | Champ | Type | Contrainte |
|---|---|---|---|
| `image` | `url` | string | vide ou `/uploads/scormia/modules/{uuid}/…`, sans `../` |
| | `alt` | string | texte brut (strip_tags) |
| | `caption` | string | texte brut (strip_tags) |
| | `variant` | int 0-2 | 0 Bord arrondi, 1 Carte légende, 2 Polaroïd |
| `video` | `mode` | `embed\|file` | embed = iframe YouTube/Vimeo/Dailymotion ; file = upload |
| | `url` | string | embed : https:// ; file : chemin module |
| | `variant` | int 0-2 | 0 Sombre, 1 Vignette titre, 2 Clair minimal |
| `audio` | `url` | string | chemin module obligatoire si non vide |
| | `caption` | string | texte brut |
| | `variant` | int 0-2 | 0 Barre compacte, 1 Carte podcast, 2 Ligne minimale |
| `embed` | `url` | string | https:// strict, ou http local (localhost/127.0.0.1, port optionnel — dev/test, jamais vu par l'apprenant en production), ou vide |
| | `height` | `small\|medium\|large` | hauteur iframe (320/480/640 px) |
| | `variant` | int 0-2 | 0 Navigateur, 1 Cadre simple, 2 Étiquette lien |

### Sécurité

- Les URLs sont échappées via `escapeAttr` dans `render()` (jamais interpolées brutes dans le HTML).
- Les textes (légende, alt) passent par `escapeHtml`.
- L'iframe embed porte : `sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation"` + `referrerpolicy="no-referrer"`.
- `collectMedia()` vérifie via `realpath` que le fichier résolu vit bien sous `MODULES_DIR` (défense contre les chemins avec `../`).
- La modale construit son DOM en `createElement`/`textContent` — aucune donnée réseau dans `innerHTML`.

### URLs collectées à l'export

`ScormPackageExporter::collectMedia()` copie dans `assets/` du ZIP les URLs commençant par
`ScormiaConfig::MODULES_DIR . '/'` (soit `/uploads/scormia/modules/`). Les URLs externes https
(embed vidéo YouTube, iframe Genially…) ne déclenchent aucune copie et restent absolues dans le
JSON embarqué — elles fonctionnent dans le ZIP via accès réseau normal.

## Ajouter un type de bloc

Trois étapes, aucun `switch` à modifier ailleurs.

### 1. Classe PHP (validation serveur)

Créer `src/Service/Scormia/Block/MonNouveauBloc.php` :

```php
<?php

declare(strict_types=1);

namespace App\Service\Scormia\Block;

final class MonNouveauBloc implements BlockTypeInterface
{
    public function key(): string   { return 'monnouveaubloc'; }
    public function label(): string { return 'Mon nouveau bloc'; }

    public function defaults(): array
    {
        return ['texte' => 'Contenu par défaut'];
    }

    public function validate(array $content): array
    {
        $errors = [];
        if (!is_string($content['texte'] ?? null)) {
            $errors[] = 'monnouveaubloc : « texte » doit être une chaîne';
        }
        return $errors;
    }

    public function normalize(array $content): array
    {
        return [
            'texte' => trim(strip_tags((string) ($content['texte'] ?? $this->defaults()['texte']))),
        ];
    }
}
```

Ajouter le tag dans `config/services.yaml` (section « Types de blocs ») :

```yaml
App\Service\Scormia\Block\MonNouveauBloc:
    tags: ['scormia.block_type']
```

`BlockTypeRegistry` l'auto-découvre via `!tagged_iterator scormia.block_type` — aucune autre modification PHP.

### 2. Fichier d’élément JS (bibliothèque partagée éditeur/player)

Créer `assets/scormia/blocks/elements/monnouveaubloc.js` :

```js
/**
 * Élément monnouveaubloc — rendu partagé + interactivité.
 * Le pendant PHP (validation serveur) : src/Service/Scormia/Block/MonNouveauBloc.php
 */
export default {
    type: ‘monnouveaubloc’,
    /** Catégorie dans le picker : ‘contenu’ | ‘medias’ | ‘interactif’. */
    category: ‘contenu’,
    /** Description courte affichée dans le picker (une ligne FR). */
    description: ‘Mon nouveau bloc’,
    /** Libellés FR des variantes visuelles (0-indexé). Absent ou ≤ 1 → pas de chips toolbar. */
    variants: [‘Variante A’, ‘Variante B’],
    /** HTML du bloc (rendu partagé éditeur/player). */
    render(c, helpers) {
        const { escapeHtml } = helpers
        return `<div class="sc-monnouveaubloc">${escapeHtml(c.texte || ‘’)}</div>`
    },
    /** Câblage d’interactivité (null pour un bloc statique). */
    init: null,
    /** Contenu d’exemple pour les vignettes DEMO_SCREEN (null = vignette factice). */
    sample: { texte: ‘Contenu d\’exemple’ },
}
```

puis l’enregistrer dans le registre `assets/scormia/blocks/elements/index.js` :

```js
import monnouveaubloc from ‘./monnouveaubloc.js’

export const ELEMENTS = {
    // …
    monnouveaubloc,
}
```

`render()` reçoit `helpers = { escapeHtml, escapeAttr, toEmbedUrl, blockKey }`.
Elle est appelée aussi bien dans le canevas de l’éditeur que dans le player SCORM
exporté — ce que l’auteur voit = ce que voit l’apprenant. `init` câble l’interactivité (voir
plus bas).

**Contrat picker** : tous les 25 types exposent `category`, `description` et `variants` directement
sur l’élément JS. `lib/picker.js` n’a plus de dictionnaire `FALLBACK` statique — seul un repli
défensif `’contenu’` couvre un éventuel type non reconnu (cas impossible en production).

### 3. Édition en place (optionnel)

Si le bloc expose un ou plusieurs champs éditables directement dans le canevas (comme `richtext`
ou `heading`), implémenter la méthode `inlineEdit` dans l'élément JS :

```js
inlineEdit(blockEl, content, commit) {
    const el = blockEl.querySelector('.sc-monnouveaubloc')
    if (!el) return
    editable(el, () => commit(() => { content.texte = el.textContent.trim() }), { singleLine: true })
},
```

Pour un bloc purement statique ou configuré via la toolbar, `inlineEdit` peut être omis
(ou `null`) — le canevas n'expose alors aucune édition en place sur ce bloc.

## Mise en production

Les deux commandes suivantes sont **idempotentes** : les réexécuter n'a aucun effet si tout
est déjà à jour. Elles se lancent en CI ou manuellement après chaque déploiement.

```bash
# 1. Appliquer les migrations DB (ne fait rien si déjà à la dernière version)
docker compose exec -T php php bin/console doctrine:migrations:migrate -n

# 2. Compiler les assets (génère public/build/entrypoints.json dont dépend l'export ZIP)
npm run build
```

> L'export ZIP lit `public/build/entrypoints.json` pour résoudre les fichiers du player.
> Si `npm run build` n'a pas été lancé après un changement de code front, l'export échoue
> avec `ScormExportException::playerBuildMissing()`.

## Test d'import Moodle

Cette procédure est à valider manuellement sur une instance Moodle de test.

### Prérequis

- Un module Scormia créé et exporté en ZIP depuis l'éditeur.
- Un cours Moodle de test avec droits d'enseignant.

### Étapes

1. Dans le cours Moodle, activer le mode édition.
2. Cliquer sur **Ajouter une activité ou une ressource**.
3. Sélectionner **Paquetage SCORM** et cliquer sur **Ajouter**.
4. Dans le champ **Paquetage**, déposer (ou téléverser) le fichier `.zip` exporté.
5. Laisser les paramètres par défaut (tentatives : illimitées, affichage : nouvelle fenêtre).
6. Enregistrer et revenir au cours.

### Points à vérifier

| Vérification | Attendu |
|---|---|
| Le module se lance | La fenêtre/iframe s'ouvre, le player s'affiche |
| Navigation | Les boutons Précédent / Suivant fonctionnent, la progression avance |
| Complétion | Après avoir visité **tous** les écrans, le statut passe à **Terminé** |
| Rapport SCORM | Dans le rapport Moodle (Tentatives utilisateur), la colonne **État** affiche **Terminé** et la colonne **Durée** est renseignée (format HH:MM:SS) |
| Mode dégradé | Ouvrir `index.html` du ZIP directement dans un navigateur : le module fonctionne sans erreur console bloquante (tracking désactivé silencieusement) |

## Contraintes SCORM 1.2

| Contrainte | Détail |
|---|---|
| **Un seul SCO** | Le ZIP contient un unique SCO (`index.html`). Moodle n'affiche pas de menu de SCOs. |
| **`cmi.core.lesson_status`** | Posé à `incomplete` à l'ouverture ; basculé à `completed` (sans quiz) ou `passed`/`failed` (avec quiz) selon la règle de complétion ci-dessous. |
| **`cmi.core.score.raw`** | Score en pourcentage 0–100 agrégé sur tous les blocs quiz du module (uniquement si des blocs quiz sont présents). |
| **`cmi.core.session_time`** | Durée de la session au format SCORM `HH:MM:SS` (ex. `00:03:47`), envoyée à `LMSFinish`. |
| **`cmi.suspend_data`** | JSON compacté de l'état de reprise (≤ 4 096 caractères — dégradation par paliers si dépassement). |
| **Mode dégradé** | Si `window.API` est introuvable (ouverture hors LMS), toutes les opérations SCORM sont ignorées silencieusement — le module reste fonctionnel. |

---

## Phase 2 — Interactivité & notes

### Nouveaux types de blocs

Neuf nouveaux types de blocs interactifs ont été ajoutés au fil des phases 2-4 : les 6 premiers (flashcards, accordion, timeline, hotspot, dragdrop, quiz) dès la phase 2 ; `match`, `fill` et `order` ajoutés en phase 3 (formatifs uniquement, non notés SCORM). Voici leur clé PHP, leur label éditeur et le schéma exact de leur champ `content`.

#### `flashcards` — Cartes à retourner

```json
{
  "cards": [
    { "front": "Recto de la carte", "back": "Verso de la carte" }
  ]
}
```

- `cards` : tableau non vide de paires `{ front, back }` (chaînes, LaTeX `\( \)` autorisé).
- Interaction : cliquer une carte la retourne (classe CSS `is-flipped`).
- Non noté ; déclenche `onInteractionSeen` au premier retournement.

#### `accordion` — Accordéon

```json
{
  "items": [
    { "title": "Section 1", "html": "<p>Contenu HTML épuré</p>" }
  ]
}
```

- `items` : tableau non vide de sections `{ title, html }`.
- `html` est nettoyé côté serveur par `HtmlCleaner`.
- Interaction : cliquer l'en-tête déplie/replie la section (`body.hidden`).
- Non noté ; déclenche `onInteractionSeen` au premier dépliage.

#### `timeline` — Frise chronologique

```json
{
  "items": [
    { "label": "2026", "title": "Étape 1", "html": "<p>Contenu HTML épuré</p>" }
  ]
}
```

- `items` : tableau non vide d'étapes `{ label, title, html }`.
- `html` nettoyé par `HtmlCleaner`.
- Interaction statique (défilement) — non câblé dans `interactions.js`, pas de `onInteractionSeen`.

#### `hotspot` — Image à zones cliquables

```json
{
  "url": "/uploads/scormia/modules/{uuid}/mon-image.jpg",
  "alt": "Description de l'image",
  "spots": [
    { "x": 42.5, "y": 30.0, "title": "Titre du point", "text": "Description longue" }
  ]
}
```

- `url` : URL d'un média uploadé dans Scormia (validé par `ScormiaConfig::isValidModuleMediaUrl`). Chaîne vide = pas d'image.
- `spots` : tableau (éventuellement vide) de zones. `x` et `y` en pourcentage (0–100).
- Interaction : cliquer un point affiche une popover `{ title, text }`.
- Non noté ; déclenche `onInteractionSeen` au premier clic.

#### `dragdrop` — Glisser-déposer

```json
{
  "instruction": "Classez les étiquettes dans la bonne catégorie.",
  "groups": [
    { "name": "Catégorie 1", "items": ["Étiquette A", "Étiquette B"] },
    { "name": "Catégorie 2", "items": ["Étiquette C"] }
  ]
}
```

- `groups` : minimum 2 catégories, chacune avec au moins une étiquette (chaîne).
- Interaction : glisser-déposer (HTML5 drag) et repli tactile/clavier (clic étiquette puis clic zone).
- Le bouton « Vérifier » colore les étiquettes en vert/rouge et affiche un score local.
- **Non noté SCORM** — feedback uniquement local, ne remonte pas dans `score.raw`.
- Déclenche `onInteractionSeen` au clic sur « Vérifier ».

#### `quiz` — Quiz QCM

```json
{
  "questions": [
    {
      "q": "Quelle est la capitale de la France ?",
      "type": "single",
      "opts": [
        { "text": "Paris", "correct": true },
        { "text": "Lyon",  "correct": false }
      ]
    },
    {
      "q": "La Terre est plate.",
      "type": "tf",
      "opts": [
        { "text": "Vrai", "correct": false },
        { "text": "Faux", "correct": true }
      ]
    }
  ]
}
```

Types de questions (schéma POC « CODE V1 » — le type est PAR QUESTION) :
- `single` : une seule bonne réponse. Minimum 2 opts, exactement 1 `correct: true` (la première gagne à la normalisation).
- `multiple` : plusieurs bonnes réponses possibles. Minimum 2 opts, au moins 1 correcte. Compte juste si **toutes** les bonnes sont cochées et **aucune** mauvaise.
- `tf` : Vrai/Faux. Opts figées `[Vrai, Faux]` (textes et nombre verrouillés), une seule correcte.

L'ancien schéma (questions `mcq`/`truefalse`/`shortanswer`, champs `text`/`options`/`correctBool`/`accepted`)
est mappé par `QuizBlock::normalize()` puis ses champs disparaissent : `mcq` → `single` ou `multiple`
selon le nombre de bonnes réponses ; `truefalse` → `tf` ; `shortanswer` → `single` (la première réponse
acceptée devient l'unique opt correcte, complétée du distracteur « Autre réponse » — plus de réponse libre).

Le quiz est **noté SCORM** : voir la section « Règle de complétion » ci-dessous.

#### `match` — Paires à associer (P3 — formatif)

```json
{ "pairs": [{ "term": "HTML", "def": "Langage de balisage" }], "variant": 0 }
```

- `pairs` : tableau non vide de paires `{ term, def }`. L'ordre stocké EST la solution (le runtime mélange).
- `variant` : 0 Deux colonnes, 1 Cartes reliées, 2 Compact.
- Bloc **formatif** : feedback local uniquement, `markSeen` au premier engagement — non noté SCORM.

#### `fill` — Texte à trous (P3 — formatif)

```json
{ "text": "Un module [SCORM] se dépose sur un [LMS]." }
```

- `text` : chaîne libre avec trous entre `[crochets]`. Le runtime détecte les trous et propose des champs de saisie.
- Bloc **formatif** : feedback local uniquement, `markSeen` au premier engagement — non noté SCORM.

#### `order` — Remettre dans l'ordre (P3 — formatif)

```json
{ "steps": ["Étape 1", "Étape 2", "Étape 3"], "variant": 0 }
```

- `steps` : tableau non vide de chaînes. L'ordre stocké EST la solution (le runtime mélange).
- `variant` : 0 Étapes, 1 Cartes, 2 Compact.
- Bloc **formatif** : feedback local uniquement, `markSeen` au premier engagement — non noté SCORM.

---

### Couche `interactions.js`

`assets/scormia/blocks/interactions.js` câble l'interactivité de tous les blocs interactifs : elle lit le type de chaque `.sc-block--{type}` et délègue à `ELEMENTS[type].init()` (implémenté dans `elements/{type}.js`). Elle est appelée dans l'éditeur (canevas) et dans le player, après l'insertion du HTML produit par `render.js` dans le DOM.

```js
import { initInteractions } from './blocks/interactions.js'

initInteractions(rootEl, context)
```

#### Contrat du `context`

| Propriété | Signature | Rôle |
|---|---|---|
| `mode` | `'editor' \| 'player'` | Contexte d'exécution |
| `getBlockContent` | `(blockKey) => object \| null` | Retourne le contenu JSON du bloc (source : module JSON) |
| `savedQuizState` | `(blockKey) => {answers, correct, total, done} \| null` | État sauvegardé d'un quiz (bloc terminé — reprise de session) ; `null` si pas de reprise ou bloc entamé |
| `savedQuizProgress` | `(blockKey) => {answers, correct, total} \| null` | **PLAYER seulement** — progression partielle d'un bloc séquentiel entamé (préfixe non vide) ; `null` si bloc vierge ou terminé |
| `onQuizValidated` | `(blockKey, {correct, total, answers}) => void` | Appelé quand le DERNIER « Valider » du bloc est confirmé (bloc entièrement noté) |
| `onQuizProgress` | `(blockKey, {answers, correct, total}) => void` | **PLAYER seulement** — appelé à chaque « Valider » intermédiaire (séquentiel) ; persiste le préfixe dans le suspend sans toucher au score |
| `getShuffleSeed` | `(blockKey) => uint32` | **PLAYER seulement** — seed de mélange stable de la session (tiré au premier accès, persisté dans le suspend, restauré à la reprise) |
| `onInteractionSeen` | `(blockKey) => void` | Appelé au premier engagement sur un bloc ludique (flashcard retournée, section dépliée, zone cliquée, etc.) |

La clé `blockKey` suit le schéma `s{screenIndex}b{blockIndex}` (ex. `s0b2` = écran 0, bloc 2).

Le corrigé d'un quiz n'est **jamais** présent dans le DOM avant validation : il est lu depuis `context.getBlockContent(key)` au moment du clic.

---

### Règle de complétion

#### Sans bloc quiz

Dès que tous les écrans ont été visités (`state.visited.size === module.screens.length`), le player appelle `scorm.setCompleted()` qui pose `cmi.core.lesson_status = completed`.

#### Avec un ou plusieurs blocs quiz

1. **Score** : calculé en temps réel à chaque validation de quiz.
   ```
   score.raw = Math.round((totalCorrect / totalQuestions) * 100)
   ```
   `totalCorrect` et `totalQuestions` sont agrégés sur **tous** les blocs quiz du module.
   Envoyé via `cmi.core.score.raw` (0–100).

2. **Statut final** : posé uniquement quand **tous** les écrans ont été vus ET **tous** les blocs quiz validés.
   ```
   score >= module.passingScore  →  passed
   score <  module.passingScore  →  failed
   ```

3. **`passingScore`** : colonne `passing_score` de `scormia_module` (entier 0–100, défaut 50). Réglable via le bouton **⚙ Réglages** de la topbar de l'éditeur (popover « Validation du quiz »). Émis dans `imsmanifest.xml` comme `<adlcp:masteryscore>` **uniquement si le module contient un quiz** (le LMS peut comparer `cmi.core.score.raw` au seuil ; le lecteur reste maître du passed/failed).

---

### Format `suspend_data`

`cmi.suspend_data` est limité à **4 096 caractères** par SCORM 1.2. Le player sérialise l'état complet en JSON puis tente de tenir dans cette limite par paliers de dégradation.

#### Structure JSON

```json
{
  "f": "3-12",
  "s": 1,
  "v": [0, 1],
  "q": {
    "s0b2": { "a": [[0], [1], [0, 2]], "c": 2, "t": 3 }
  },
  "i": ["s1b0", "s2b1"],
  "r": { "s0b2": 2847391054 }
}
```

| Champ | Contenu |
|---|---|
| `f` | Empreinte du module : `"{nbÉcrans}-{nbBlocsTotal}"` — si différente à la reprise, l'état sauvegardé est ignoré |
| `s` | Index de l'écran courant |
| `v` | Tableau des indices d'écrans visités |
| `q` | États des quiz : `a` = tableau de réponses par question (indices cochés), `c` = bonnes réponses, `t` = total questions. En mode séquentiel entamé, `a` est un préfixe (longueur < `t`) |
| `i` | Clés des interactions ludiques engagées (flashcard, accordéon, hotspot…) |
| `r` | **Phase 5** — Seeds de mélange (blockKey → uint32 strictement positif). Champ **optionnel** : un suspend sans `r` reste valide (de nouveaux seeds sont tirés à la reprise). Format clés identique à `q`. Budget : ≈ 18 chars par entrée (`"sXbY":NNNNNNNNNN`) — voir ci-dessous |

#### Budget `cmi.suspend_data`

Estimation pire cas (10 écrans, 25 blocs, 5 quiz de 5 questions chacun avec réponses multiple, 5 blocs interactifs, toutes interactions vues, 10 seeds) :

| Scénario | Taille JSON |
|---|---|
| Module réaliste (10 écrans, 25 blocs, 5 quiz×5q, 10 seeds) | **≈ 880 chars** |
| Champ `r` seul (10 seeds, uint32 max) | 187 chars |
| Palier 3 (sans `r`) | ≈ 400 chars |

Marge confortable : **> 3 000 chars** avant d'atteindre 4 096.

#### Paliers de dégradation (serialize)

Si le JSON complet dépasse 4 096 caractères, les paliers suivants sont tentés dans l'ordre :

| Palier | Données conservées |
|---|---|
| 1 (complet) | Tout (`f`, `s`, `v`, `q`, `i`, `r`) |
| 2 | Sans `i` (interactions vues effacées) |
| **3** | **Sans `i`, sans `r` (seeds de mélange effacés — l'ordre est re-tiré à la reprise, les réponses sont conservées)** |
| 4 | Sans `i`, sans `r`, réponses textuelles legacy vidées (score conservé) |
| 5 | Position + écrans vus SEULEMENT (quiz vidés — `q:{}`), sans interactions ni seeds |
| Dernier recours | `{ f, s: 0, v: [0], q: {}, i: [] }` |

> **Règle clé** : perdre un ordre d'affichage (palier 3) est préférable à perdre des réponses (palier 4+). Un suspend restauré sans `r` re-tire de nouveaux seeds au premier accès de chaque bloc.

#### Restauration (restore)

Au démarrage du player, si `cmi.suspend_data` est non vide et que l'empreinte `f` correspond au module courant, l'état est restauré :
- Navigation reprend à l'écran `s`
- Écrans visités et quiz verrouillés (réponses affichées, ordre mélangé rejoué si `r` présent)
- Quiz entamés (séquentiel, préfixe `a` plus court que `t`) : reprise à la première question sans réponse
- Si `r` absent (suspend legacy ou palier 3) : de nouveaux seeds sont tirés, l'ordre peut différer mais les réponses sont toujours restaurées correctement (les values DOM portent les indices réels, indépendants de l'ordre d'affichage)
- Si l'empreinte ne correspond pas (module modifié), la reprise est ignorée et le module repart du début.

#### Re-tentative après complétion

Lorsque le module est `passed`/`failed`, `cmi.core.exit = suspend` est posé à la fermeture. À la réouverture, Moodle (ou tout LMS SCORM 1.2) pose `cmi.core.entry = resume` et conserve `cmi.suspend_data`. Le player charge le suspend : tous les écrans sont verrouillés (quiz `done: true`), la carte de score s'affiche. **Il n'y a pas de mécanisme de nouvelle tentative dans le player P5** : repartir de zéro nécessite que le LMS efface `cmi.suspend_data` (paramètre « Nombre de tentatives » dans Moodle). C'est un point à traiter en P6 si une re-tentative en-LMS est souhaitée.

---

### Fausse API persistante (aperçu)

En mode aperçu (`/scormia/module/{uuid}/preview`), `installFakeApi()` (dans `scorm_api.js`) installe `window.API` avant le chargement du player.

**Comportement :**
- Toutes les valeurs SCORM sont stockées dans `sessionStorage` sous la clé `scormia-preview-{uuid}`.
- Chaque appel est journalisé en console avec le préfixe `[Scormia SCORM]`.
- `LMSCommit` persiste immédiatement le store dans `sessionStorage`.

**Tester la reprise dans l'aperçu :**

1. Ouvrir l'aperçu du module (`/scormia/module/{uuid}/preview`).
2. Naviguer sur plusieurs écrans et/ou valider le quiz.
3. Observer en console : `[Scormia SCORM] cmi.suspend_data = {...}`.
4. **Recharger l'onglet** (F5) — le player reprend à l'écran sauvegardé, le quiz apparaît verrouillé avec les réponses de la session précédente.
5. Pour repartir de zéro : vider le `sessionStorage` du domaine (`Application > Storage > Session Storage` dans les DevTools) ou ouvrir un nouvel onglet en navigation privée.

---

### Mise à jour : ajouter un type de bloc interactif

La procédure de base reste identique (classe PHP + `elements/{type}.js` enregistré dans `elements/index.js`, avec `variants`/`category`/`description` et `inlineEdit` pour l'édition en place). Pour un bloc interactif, remplir la méthode `init` du fichier d'élément au lieu de la laisser à `null` :

```js
// Dans assets/scormia/blocks/elements/monbloc.js :
export default {
    type: 'monbloc',
    render(c, helpers) { /* … */ },
    init(blockEl, context, helpers) {
        const { markSeen } = context
        blockEl.querySelector('.sc-monbloc__btn').addEventListener('click', () => {
            // … logique interactive …
            markSeen(blockEl)  // déclenche onInteractionSeen (une seule fois par bloc)
        })
    },
    sample: { /* … */ },
}
```

`initInteractions()` (dans `interactions.js`) appelle automatiquement cet `init` pour chaque `.sc-block--monbloc` du DOM — aucun dispatch à modifier. `context.markSeen(blockEl)` lit `blockEl.dataset.scKey` (posé par `render.js`) et appelle `context.onInteractionSeen(key)` une seule fois par bloc (dédupliqué par un `Set` interne).

#### Bloc quiz noté

Si le bloc doit contribuer au score SCORM, appeler `context.onQuizValidated(key, { correct, total, answers })` à la validation et implémenter la restauration via `context.savedQuizState(key)`. Voir `elements/quiz.js` comme référence complète.

---

## Phase 5 — Randomisation et quiz séquentiel

### Vue d'ensemble

La phase 5 ajoute deux comportements au player :

1. **Mélange déterministe** — les options des blocs quiz (et les items de `dragdrop`, `match`, `order`) sont réordonnés à l'affichage selon un seed uint32 stable par session. L'ordre est reproduit identiquement à la reprise.
2. **Quiz une-par-une** — les questions d'un bloc quiz sont présentées séquentiellement avec feedback immédiat (« Valider » question par question), au lieu du mode « toutes d'un coup ».

Ces deux fonctionnalités sont entièrement côté player : `render()` reste partagé et ne mélange jamais (l'éditeur montre la solution dans l'ordre stocké).

### Mélange — `assets/scormia/player/shuffle.js`

Trois exports :

| Fonction | Signature | Description |
|---|---|---|
| `mulberry32(seed)` | `(number) => () => number` | PRNG 32 bits déterministe (domaine public). Même seed → même suite sur tous les navigateurs. |
| `shuffledIndices(n, seed)` | `(number, number) => number[]` | Permutation Fisher-Yates de `[0…n-1]` pilotée par `mulberry32(seed)`. Même `(n, seed)` → même ordre. |
| `drawSeed()` | `() => number` | Tire un seed uint32 strictement positif (`crypto.getRandomValues` ou repli `Math.random`). |

**Règle d'immuabilité** : l'algorithme (formule `mulberry32` + Fisher-Yates) et la dérivation du seed par question (voir ci-dessous) ne doivent **jamais changer** — les changer casserait l'ordre des modules en cours de reprise.

#### Dérivation du seed par question (bloc quiz)

Pour éviter que toutes les questions d'un même bloc reçoivent la même permutation :

```js
qSeed = (seed ^ Math.imul(qi + 1, 0x9E3779B9)) >>> 0
```

(`0x9E3779B9` = ratio doré 32 bits, `qi + 1` évite que la question 0 coïncide avec le seed brut.)

Les questions `tf` (Vrai/Faux) ne sont **pas mélangées** — l'ordre Vrai/Faux est une convention attendue par l'apprenant.

#### Seed stable par session (`ctx.getShuffleSeed`)

Le player expose `context.getShuffleSeed(blockKey)` :

- Si le seed de ce bloc est déjà dans `state.shuffleSeeds` (restauré du suspend), il est retourné tel quel.
- Sinon, un nouveau seed est tiré par `drawSeed()`, persisté immédiatement dans `state.shuffleSeeds` et sauvegardé dans le suspend — toute reprise ultérieure rejoue exactement le même ordre.

Les blocs `dragdrop`, `match` et `order` consomment ce même contexte pour mélanger leurs items.

### Quiz séquentiel (une question à la fois)

`elements/quiz.js` — fonction `initSequential` :

- La div `.sc-quiz--seq` est ajoutée à la racine ; seule la question `is-current` est visible (CSS).
- Un compteur `Question N/M` est affiché (`aria-live="polite"` pour l'accessibilité).
- « Valider » est désactivé tant qu'aucune case n'est cochée.
- À chaque validation intermédiaire : `context.onQuizProgress(key, {answers, correct, total})` — le suspend est écrit sans toucher au score SCORM.
- À la dernière validation : `context.onQuizValidated(key, {correct, total, answers})` — le score et le statut SCORM sont mis à jour (même contrat qu'avant la phase 5).
- Après la dernière question, « Voir le résultat » affiche le récapitulatif (liste ✓/✗).

**Reprise d'un bloc entamé** (préfixe de réponses dans le suspend) :

Le bloc reprend à la première question sans réponse : les questions déjà validées sont re-corrigées et figées ; le curseur est avancé au bon index. Si le préfixe couvre toutes les questions (cas limite : fermeture entre validation finale et écriture du `done`), le bloc est complété directement.

### Formatifs non notés (`dragdrop`, `match`, `order`, etc.)

Ces blocs sont interactifs mais **ne contribuent pas au score SCORM** (`onQuizValidated` n'est jamais appelé). Le mélange y est appliqué par leur `init()` via `context.getShuffleSeed` — le seed est donc persisté dans le suspend (champ `r`) même pour ces blocs non notés. Le résultat est purement local (feedback visuel immédiat).

---

## Génération IA (`ScormiaGenerationService`)

`src/Service/Scormia/ScormiaGenerationService.php` génère un module complet à partir d'une source (PDF, texte collé ou simple sujet) via plusieurs appels séquentiels au modèle.

### Modèle : unique (« IA Standard »)

Il n'existe plus qu'**un seul modèle** (le palier « Avancé »/gpt-5 a été retiré). Côté client, seul le libellé maison « IA Standard » est exposé — jamais un nom de modèle.

- `OpenAIConfig::SCORMIA_MODEL` (`gpt-4.1`) : **Chat Completions API** — `temperature` transmise, et `response_format: json_object` ajouté pour toutes les phases JSON (fiabilité maximale ; la phase synthèses reste en texte simple, sans response_format).
- Garde-fou de contenu : `dropEmptyRichtext()` écarte les blocs `richtext` au HTML vide renvoyés par une réponse IA dégradée (et LOG un extrait de la réponse brute), évitant un module rempli de blocs `<p></p>` vides.

### Architecture multi-phases

```
generate(source, options)
├── Phase 0 — INGESTION
│   ├── chunkSource() : découpe en morceaux ~30 000 chars (frontière \n\n)
│   │   Garde-fou : MAX_SOURCE_CHARS = 2 000 000 (tronqué + warning)
│   └── 1 morceau → chemin direct (pas de synthèse)
│       N morceaux → summarizeChunk() × N (SYNTHESIS_MAX_TOKENS = 1 500)
│
├── Phase 1 — PLAN
│   └── generatePlan() — 1 appel (PLAN_MAX_TOKENS = 10 000)
│       Produit : summary, objectives, nodes (title, depth, brief, topics, chunks)
│       Mapping morceaux : indices base 1 côté prompt → base 0 côté PHP (parsePlanNodes)
│       Nœud sans mapping valide → TOUS les morceaux par défaut
│
├── Phase 2 — CONTENU PAR NŒUD
│   └── generateNodeContent() × ≤ 48 nœuds (MAX_CONTENT_NODES)
│       Source du nœud : nodeSource() — ses morceaux mappés, plafond 60 000 chars
│       Budget : CONTENT_MAX_TOKENS = 12 000
│       Gamification :
│         - Prompt système expose les 8 types interactifs (schémas réels des classes Block)
│         - Règle obligatoire : ≥ 1 exercice par section (flashcards/dragdrop/match/fill/order/quiz)
│         - accordion et timeline ne comptent pas comme exercice
│         - Rotation : fenêtre glissante 4 nœuds (recentInteractiveTypes) → prompt suivant
│         - Filet dur PHP : nœud sans interactif → flashcards injecté à ceil(n×2/3)
│         - Repli par nœud : exception → fallbackNodeBlocks() ; génération continue
│       Borne globale : MAX_LLM_CALLS = 60 (quiz réserve 1 slot) ; au-delà → heading minimal
│
└── Phase 3 — QUIZ FINAL
    └── generateFinalQuiz() — 1 appel dédié (QUIZ_MAX_TOKENS = 8 000)
        Entrée : plan complet + synthèses (ou morceau unique)
        8-12 questions mixtes (single/multiple/tf), couvrant tout le module
        Repli : fallbackQuiz() seulement sur échec (questions méta Vrai/Faux sur les objectifs)
        withQuiz=false → aucun appel
```

### Assemblage final

`moduleJson` retourné : `{title, screens:[cover, ...nœuds..., completion]}`. `applyJsonToModule()` reconstruit les entités DB et **normalise** chaque bloc via `BlockTypeRegistry::get(type)->normalize()` — tout contenu IA mal formé est corrigé sans erreur. Type inconnu → repli `richtext` (module jamais cassé).

### Mode source — 3 entrées possibles

| Mode | Saisie utilisateur | `$source` passé à `generate()` |
|---|---|---|
| PDF | Fichier PDF uploadé | Texte extrait par `PdfExtractorService::extractContent()` (inchangé) |
| Texte | Textarea `source` (≥ 20 chars) | Le texte collé |
| Sujet | Champ `source` rempli avec le sujet (≥ 20 chars) | Le sujet saisi |

Le contrôleur passe `title` et `source` **séparément** dans les options et dans la source. La `sourceNote` du prompt contenu (heuristique `trim($source) === trim($node['title'])`) ne se déclenche **presque jamais** en mode sujet — voir concerns ci-dessous.

### Latences estimées

| Scénario | Appels LLM | Durée estimée (~10-20 s/appel gpt-4.1) |
|---|---|---|
| Petit doc (1 morceau, 6 nœuds) | 8 | ~2,5-5 min |
| Gros PDF (10 morceaux, 30 nœuds) | 41 | ~12-24 min |

Le front (`configure_controller.js`) affiche un loader plein écran sans timeout `fetch` côté JS — la génération tient tant que la connexion HTTP reste ouverte. **En production**, vérifier `request_terminate_timeout` FPM : recommandé à `0` (infini) ou `> 1500 s` pour le pool exposant `/scormia/generate`.

### Concerns résiduels

1. **Heuristique sujet inopérante** : `sourceNote` (invite « tu peux compléter par des connaissances générales ») ne se déclenche pas en mode sujet, car `trim($source)` (le sujet saisi) ≠ `trim($node['title'])` (titre de section généré). Sans impact : le prompt système porte déjà cette règle.
2. **Timeout FPM** : seul point opérationnel critique pour les gros PDF (voir ci-dessus).
3. **Réponse IA dégradée** : si l'IA renvoie la structure des blocs sans contenu (`richtext` vides), `dropEmptyRichtext()` les écarte et LOG un extrait de la réponse brute (chercher « blocs richtext vides écartés » dans les logs).


## Génération IA asynchrone (2026-07-13)

La génération ne bloque plus de worker HTTP : `POST /scormia/generate` extrait le
PDF, crée un `ScormiaGenerationJob`, pousse un message Messenger (transport dédié
`scormia_generation`, `max_retries: 0`) et répond **202** `{jobUuid, tierLabel}`.
Le **worker** (`messenger:consume scormia_generation`, service docker
`scormia_worker`, 1 génération à la fois) exécute `ScormiaGenerationService::generate`
avec un callback `$onProgress` (étapes pondérées 15/10/65/8/2). Le front sonde
`GET /scormia/generation/{uuid}` toutes les 2,5 s (barre + étape réelle, onglet
fermable, reprise via sessionStorage, carte « génération en cours » sur la
bibliothèque). `POST .../cancel` = UPDATE DQL conditionnel (anti-race worker).

**Modèle unique** : `ScormiaModelResolver` renvoie toujours `SCORMIA_MODEL`
(`gpt-4.1`) — le palier « Avancé »/gpt-5 a été retiré. UI : libellé maison
« IA Standard » — **jamais** de nom de modèle exposé. `chat()` appelle
Chat Completions avec `response_format: json_object`/`temperature`.

**Non fait** : parallélisation par lots (optimisation de vitesse) — l'architecture
worker règle déjà la charge serveur.
