# Slidia v2 — Étape 2b : documents et fidélité — Plan d'implémentation

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal :** permettre à Slidia de partir d'un document PDF, même volumineux, et de respecter à la lettre une structure que l'utilisateur a déjà rédigée.

**Architecture :** un principe gouverne tout — condenser pour **planifier**, jamais pour **rédiger**. Le sommaire produit par un modèle bon marché sert uniquement à décider de la structure ; la rédaction reçoit toujours le texte source intégral des sections concernées. En parallèle, un analyseur détecte les briefs déjà structurés et bascule la génération en mode transcription, où le modèle ne fait plus que répartir et mettre en forme.

**Tech Stack :** Symfony 7.3, PHP 8.2+, `smalot/pdfparser`, composant Cache de Symfony, PHPUnit 12, Stimulus 3.

**Spec de référence :** `docs/specs/2026-08-01-slidia-v2-ia-design.md` (sections 3, 6, 7)

## Global Constraints

- Branche de travail : `slidia_v2`. Ne pas merger.
- **Ne jamais utiliser `git add -A`, `git add .` ni `git commit -a`.** Stager nommément les fichiers de sa tâche : l'arbre de travail contient des modifications préexistantes appartenant au propriétaire du projet.
- **Ne modifier aucun fichier Scormia.** `src/Service/Moodia/PdfExtractorService.php` est en revanche **lu et réutilisé** : il n'a aucune dépendance à Moodia hormis son espace de noms.
- **Le format persisté de `slidesData` reste inchangé** : `{layoutPath, fields: {clé => valeur}, notes}`, jamais d'identifiant de slide. Une assertion le verrouille déjà dans `SlidiaGenerateStreamTest`.
- Tout nouveau fichier PHP commence par `declare(strict_types=1);`.
- Commentaires, messages de commit et vocabulaire métier en **français**.
- Tests : `php bin/phpunit`, et `php -d memory_limit=1G bin/phpunit tests/Functional/` pour les fonctionnels. Références au départ de 2b : **2166 unitaires, 510 fonctionnels**.
- **Aucun test ne doit appeler l'API OpenAI** : doubler `OpenAIClientInterface`.
- **`make:migration` est inexploitable** : la base de développement porte 8 migrations absentes de cette branche et sa sortie contient des `DROP COLUMN` sur des colonnes réelles. Toute migration se écrit à la main, sur le modèle de `migrations/Version20260801100000.php`, et ne s'exécute pas.

## Ce que l'étape 2a a livré et dont 2b dépend

| Élément | Ce qu'il apporte |
|---|---|
| `SlidiaAiService` | `generatePlan()` et `generateBatchContent()` avec sorties structurées, validation stricte, assemblage par identifiant. Injecte `OpenAIClientInterface`, `LayoutProjector` et `LoggerInterface`. |
| `TokenUsageAccumulator` | Cumul de la consommation d'une génération multi-appels. Instancié par `new`, jamais injecté. |
| `SlidiaPresentation::$sourceHash` | Champ déjà en base (migration écrite), inutilisé jusqu'ici. C'est la clé du cache de 2b. |
| `SlidiaPresentation::$generationProfile` | `standard` ou `approfondi`, pilote l'effort de raisonnement. |
| `OpenAIConfig::SLIDIA_LIGHT_MODEL` | `gpt-5-nano`, déclaré et jusqu'ici inutilisé. C'est le modèle de condensation. |
| `AppLimits::SLIDIA_BRIEF_MAX_CHARS` | 300 000 caractères, appliqué avant génération. |

---

## Structure des fichiers

| Fichier | Responsabilité |
|---|---|
| `src/Service/Slidia/BriefStructureAnalyzer.php` | *(créé)* Détection d'un brief déjà structuré, sans appel IA |
| `src/Service/Slidia/DocumentSegmenter.php` | *(créé)* Découpage d'un texte en sections ancrées, sans appel IA |
| `src/Service/Slidia/DocumentSummarizer.php` | *(créé)* Sommaire d'un document volumineux, via `gpt-5-nano` |
| `src/Service/Slidia/SlidiaDocumentService.php` | *(créé)* Orchestration extraction → segmentation → sommaire, avec cache |
| `src/Config/Slidia/SummarySchema.php` | *(créé)* Schéma de sortie du sommaire |
| `src/Service/Slidia/SlidiaAiService.php` | *(modifié)* Mode fidèle, sections ciblées par lot |
| `src/Controller/Slidia/SlidiaApiController.php` | *(modifié)* Route d'import de document |
| `src/Controller/Slidia/SlidiaController.php` | *(modifié)* Branchement du pipeline documentaire |
| `src/Entity/SlidiaPresentation.php` | *(modifié)* Champ `fidelityMode` |
| `templates/slidia/create.html.twig` | *(modifié)* Dépôt de PDF et interrupteur — **provisoire, remplacé à l'étape 3** |
| `assets/controllers/slidia/create_controller.js` | *(modifié)* Idem |

---

## Task 1 — détection d'un brief structuré

**Files:**
- Create: `src/Service/Slidia/BriefStructureAnalyzer.php`
- Test: `tests/Unit/Service/Slidia/BriefStructureAnalyzerTest.php`

**Interfaces:**
- Produces : `BriefStructureAnalyzer::analyze(string $brief): ?array` — retourne `null` si aucune structure fiable n'est détectée, sinon `['sections' => list<array{title: string, content: string}>, 'marker' => string]` où `marker` nomme le motif reconnu.

**Le principe qui gouverne cette tâche : mieux vaut ne rien détecter que détecter à tort.** Un faux positif fait basculer une génération en mode transcription et produit des slides brutes là où l'utilisateur attendait un travail de structuration. Un faux négatif ne coûte que le comportement actuel. Le seuil est donc délibérément conservateur.

Cinq motifs à reconnaître, du plus explicite au plus faible :

1. mention explicite de slides — `Slide 3 :`, `Diapositive 2 —`, `Slide 3.` ;
2. en-têtes Markdown — `## Titre` en début de ligne ;
3. titres numérotés — `1.`, `2.`, ou `I.`, `II.` en début de ligne, suivis d'un espace et d'un texte ;
4. séparateurs répétés — `---` ou `===` isolés sur leur ligne, au moins trois fois ;
5. titres courts suivis de puces — une ligne de moins de 60 caractères sans ponctuation finale, suivie d'au moins deux lignes commençant par `-`, `•` ou `*`.

Règles de décision, à respecter telles quelles :

- **au moins trois sections** sont nécessaires ; en deçà, `null` ;
- **le premier motif reconnu l'emporte** : on ne combine pas les motifs, un document mélangeant les styles est trop ambigu pour être transcrit fidèlement ;
- une section dont le contenu est vide est **conservée** — une slide de titre seul est légitime ;
- le titre est nettoyé de son marqueur (`## `, `1. `, `Slide 3 : `) ; le contenu conserve sa mise en forme, y compris ses puces.

- [ ] **Étape 1 : écrire le test qui échoue**

Créer `tests/Unit/Service/Slidia/BriefStructureAnalyzerTest.php`. Couvrir, au minimum :

- les cinq motifs, chacun avec un exemple réaliste d'au moins trois sections, en vérifiant les titres extraits **et** les contenus ;
- deux sections seulement → `null` ;
- de la prose : trois paragraphes séparés par des lignes vides, sans titre → `null` ;
- un texte mêlant en-têtes Markdown et titres numérotés → le motif le plus explicite gagne, l'autre est ignoré ;
- une section à contenu vide → conservée ;
- les marqueurs sont retirés des titres ;
- une chaîne vide → `null` ;
- un texte contenant un tiret en milieu de phrase ne déclenche pas le motif « séparateur ».

Chaque test doit porter un nom en français décrivant le comportement, pas le motif technique.

- [ ] **Étape 2 : lancer le test et vérifier qu'il échoue**

```bash
php bin/phpunit tests/Unit/Service/Slidia/BriefStructureAnalyzerTest.php --testdox
```

- [ ] **Étape 3 : écrire le service**

Classe finale, sans dépendance injectée — c'est de l'analyse de texte pure, donc trivialement testable. Une méthode publique `analyze()`, une méthode privée par motif, chacune renvoyant `?array`. `analyze()` les essaie dans l'ordre et retourne le premier résultat d'au moins trois sections.

Documenter en tête de classe **pourquoi** le seuil est conservateur : c'est la décision de conception la plus importante du fichier, et le prochain lecteur voudra la baisser.

- [ ] **Étape 4 : lancer les tests**

```bash
php bin/phpunit tests/Unit/Service/Slidia/ --testdox
php bin/console lint:container
```

- [ ] **Étape 5 : commit**

```bash
git add src/Service/Slidia/BriefStructureAnalyzer.php tests/Unit/Service/Slidia/BriefStructureAnalyzerTest.php
git commit -m "feat(slidia): détection d'un brief déjà structuré, sans appel IA"
```

---

## Task 2 — le mode « format précis »

**Files:**
- Modify: `src/Entity/SlidiaPresentation.php`
- Create: `migrations/Version*.php` (à la main)
- Modify: `src/Service/Slidia/SlidiaAiService.php`
- Modify: `src/Controller/Slidia/SlidiaController.php`
- Test: `tests/Unit/Service/Slidia/SlidiaAiServiceTest.php`, `tests/Functional/Controller/Slidia/SlidiaGenerateStreamTest.php`

**Interfaces:**
- Produces :
  - `SlidiaPresentation::isFidelityMode(): bool` / `setFidelityMode(bool): static`, colonne `fidelity_mode` (booléen, défaut `false`)
  - `SlidiaAiService::planFromStructure(array $structure, array $layouts): array` — même forme de retour que `generatePlan()`
  - `SlidiaAiService::generateBatchContent(..., bool $fidelity = false)`

En mode fidèle, la phase de plan **ne demande plus de plan au modèle** : le plan *est* la structure détectée. Le modèle n'intervient que pour associer un masque à chaque slide — un appel court, avec un schéma restreint à `{id, layoutPath}`.

La phase de rédaction reçoit une instruction de transcription. Deux libertés, et deux seulement : ajouter une couverture si l'utilisateur n'en a pas prévu, une conclusion si sa structure n'en comporte pas. Toutes deux signalées dans le résultat.

Si un contenu déborde de sa zone, il est **conservé tel quel** — la fidélité prime sur l'esthétique, c'est le sens même du mode. L'utilisateur ajustera dans l'éditeur.

- [ ] **Étape 1 : écrire les tests qui échouent**

Couvrir : le plan issu d'une structure a autant de slides que de sections et reprend les titres à l'identique ; le modèle n'est appelé que pour les masques ; le prompt de rédaction en mode fidèle contient l'instruction de transcription et pas les consignes de reformulation ; une couverture est ajoutée si absente et signalée ; le mode ne s'active pas quand `fidelityMode` vaut `false`.

- [ ] **Étape 2 : lancer les tests et vérifier qu'ils échouent**

- [ ] **Étape 3 : ajouter le champ et sa migration**

Migration écrite à la main, un seul `ADD fidelity_mode TINYINT(1) DEFAULT 0 NOT NULL`, `down()` symétrique. Ne pas l'exécuter.

- [ ] **Étape 4 : implémenter le mode**

Un troisième prompt, `buildFidelityPrompt()`, dans le même style que les deux autres : minuscules, phrases affirmatives, aucune capitale d'insistance. Il dit l'essentiel — reprendre titres et contenus tels quels, ne rien reformuler, ne rien condenser, ne rien ajouter ; le seul travail est la mise en forme (puces, gras) et la répartition dans les zones du masque.

- [ ] **Étape 5 : brancher le contrôleur**

`generateStream()` bascule sur `planFromStructure()` quand `isFidelityMode()` est vrai et que l'analyse retourne une structure. Si le mode est demandé mais qu'aucune structure n'est détectée — cas d'un utilisateur ayant forcé l'interrupteur —, journaliser un `warning` et retomber sur la génération normale plutôt que d'échouer.

- [ ] **Étape 6 : vérifier et committer**

```bash
php bin/phpunit tests/Unit/ --testdox
php -d memory_limit=1G bin/phpunit tests/Functional/ --testdox
```

---

## Task 3 — extraction de document

**Files:**
- Create: `src/Service/Slidia/SlidiaDocumentExtractor.php`
- Test: `tests/Unit/Service/Slidia/SlidiaDocumentExtractorTest.php`

**Interfaces:**
- Produces : `SlidiaDocumentExtractor::extract(string $path): string` — texte brut, lève `\RuntimeException` si le fichier est illisible.

Enveloppe mince autour de `App\Service\Moodia\PdfExtractorService`, dont `extractAll(string $pdfPath): array{content: string, pageTexts: array<int,string>}` fait déjà tout le travail : `smalot/pdfparser`, nettoyage du texte, repli sur `pdftotext` si l'extraction rend moins de 50 caractères.

**Pourquoi une enveloppe plutôt qu'un appel direct :** le service de Moodia est marqué `@deprecated` sur sa méthode la plus pratique et vit dans l'espace de noms d'un autre outil. L'enveloppe donne à Slidia un point d'entrée stable, nommé dans son domaine, et un seul endroit à changer si `PdfExtractorService` bouge. Elle appelle `extractAll()['content']`, pas la méthode dépréciée.

Elle applique aussi le plafond de volume : au-delà de `AppLimits::SLIDIA_BRIEF_MAX_CHARS`, une exception dédiée porte le volume constaté et la limite, pour que le message à l'utilisateur puisse être précis.

- [ ] **Étape 1 : écrire le test qui échoue**

Doubler `PdfExtractorService`. Couvrir : le texte est bien celui de `content` ; un fichier illisible propage l'exception ; un texte au-delà du plafond lève l'exception dédiée avec le volume constaté.

- [ ] **Étape 2 : lancer le test, vérifier l'échec, implémenter, vérifier, committer**

---

## Task 4 — segmentation

**Files:**
- Create: `src/Service/Slidia/DocumentSegmenter.php`
- Test: `tests/Unit/Service/Slidia/DocumentSegmenterTest.php`

**Interfaces:**
- Produces : `DocumentSegmenter::segment(string $text): array` — `list<array{index: int, title: string, text: string, offset: int, length: int}>`

Découpage local, sans appel IA. `offset` et `length` sont l'ancrage dans le texte d'origine : c'est ce qui permettra, en phase de rédaction, de renvoyer le texte source exact plutôt qu'un résumé. **C'est la pièce sur laquelle repose la promesse de qualité de toute l'étape** — sans ancrage fiable, la rédaction se ferait sur le sommaire.

Motifs de découpe, dans l'ordre : titres numérotés, en-têtes Markdown, lignes en majuscules de moins de 80 caractères, puis à défaut des blocs de taille fixe respectant les frontières de paragraphe.

Un texte sans aucun motif reconnaissable donne une seule section couvrant tout le texte — jamais un tableau vide.

- [ ] **Étape 1 : écrire le test qui échoue**

Couvrir : chaque motif ; l'ancrage (`substr($texte, $offset, $length)` redonne exactement `text`) ; un texte sans motif donne une section unique ; une chaîne vide donne un tableau vide ; les sections couvrent le texte sans trou ni chevauchement.

Ce dernier test est le plus important : écris-le comme une propriété vérifiée sur trois textes différents.

- [ ] **Étape 2 : lancer le test, vérifier l'échec, implémenter, vérifier, committer**

---

## Task 5 — sommaire d'un document volumineux

**Files:**
- Create: `src/Config/Slidia/SummarySchema.php`
- Create: `src/Service/Slidia/DocumentSummarizer.php`
- Test: `tests/Unit/Service/Slidia/DocumentSummarizerTest.php`

**Interfaces:**
- Consumes : `OpenAIClientInterface`, `DocumentSegmenter`
- Produces : `DocumentSummarizer::summarize(array $sections): array` — `['summary' => list<array{index: int, title: string, synopsis: string}>, 'usage' => array, 'model' => string]`

Un seul appel à `gpt-5-nano`, effort `minimal`, avec sortie structurée. Le modèle reçoit les sections tronquées à leurs premiers caractères — le sommaire n'a pas besoin du texte intégral — et rend pour chacune un titre normalisé et deux à trois phrases de synthèse.

**Le contrat qui protège la qualité :** ce sommaire ne sert qu'à établir le plan. Il ne doit jamais atteindre la phase de rédaction. Le documenter en tête de classe.

Le schéma impose que chaque entrée porte l'`index` de sa section : c'est lui qui permettra de retrouver le texte source.

- [ ] **Étape 1 : écrire le test qui échoue**

Couvrir : un seul appel, sur le modèle léger, à l'effort minimal ; le schéma est transmis ; les sections envoyées sont tronquées ; la sortie conserve l'index de chaque section ; l'usage est remonté pour le comptage ; une réponse inexploitable lève `EmptyResponseException`.

- [ ] **Étape 2 : lancer le test, vérifier l'échec, implémenter, vérifier, committer**

---

## Task 6 — orchestration et cache

**Files:**
- Create: `src/Service/Slidia/SlidiaDocumentService.php`
- Test: `tests/Unit/Service/Slidia/SlidiaDocumentServiceTest.php`

**Interfaces:**
- Consumes : `SlidiaDocumentExtractor`, `DocumentSegmenter`, `DocumentSummarizer`, `CacheInterface`, `LoggerInterface`
- Produces : `SlidiaDocumentService::prepare(string $path): array` — `['hash' => string, 'sections' => array, 'summary' => ?array, 'usage' => ?array, 'model' => ?string, 'fromCache' => bool]`

Enchaîne extraction, segmentation, puis sommaire **seulement si** le texte dépasse 50 000 caractères — en deçà, `summary` vaut `null` et le plan se fera sur le texte direct.

**Le cache** est indexé par `hash_file('sha256', $path)`. Le scénario visé est banal : l'utilisateur n'aime pas le résultat et relance sur le même document. Il ne doit ni repayer l'analyse, ni la réattendre.

Deux contraintes sur le cache, à respecter :
- la valeur stockée est **encodée en JSON**, jamais un objet sérialisé — c'est la convention posée par `ContactController`, seul autre usage applicatif du cache dans ce projet ;
- l'adaptateur est le système de fichiers, purgé à chaque `cache:clear` donc à chaque déploiement. Un défaut de cache ne doit jamais faire échouer une génération : en cas de valeur illisible, journaliser et recalculer.

Le `hash` retourné alimente `SlidiaPresentation::$sourceHash`, déjà en base depuis 2a.

- [ ] **Étape 1 : écrire le test qui échoue**

Couvrir : sous le seuil, aucun appel au résumeur ; au-dessus, un appel ; le second appel sur le même fichier ne recalcule rien et `fromCache` vaut vrai ; une entrée de cache corrompue est journalisée puis recalculée ; l'empreinte est stable pour un même contenu.

- [ ] **Étape 2 : lancer le test, vérifier l'échec, implémenter, vérifier, committer**

---

## Task 7 — rédaction sur les sections sources

**Files:**
- Modify: `src/Service/Slidia/SlidiaAiService.php`
- Test: `tests/Unit/Service/Slidia/SlidiaAiServiceTest.php`

**Interfaces:**
- Produces : `generateBatchContent(array $planSlides, array $allLayouts, string $brief, string $accentColor, ?array $sections = null, bool $fidelity = false, string $language = 'fr')`

**C'est la tâche qui tient la promesse de qualité de toute l'étape.** Quand `$sections` est fourni, chaque lot ne reçoit plus le brief intégral mais **le texte source des seules sections rattachées aux slides du lot**. Le sommaire n'apparaît nulle part.

Le rattachement se fait par l'index de section, que le plan porte depuis la phase 1. Une slide sans section rattachée reçoit le texte des sections voisines, plutôt que rien.

Effet de bord recherché : le brief entier n'est plus réexpédié à chaque lot. Moins de tokens, et surtout moins de dilution — un lot qui reçoit 6 000 tokens ciblés produit un meilleur contenu qu'un lot noyé dans 50 000.

**L'ordre des messages reste inchangé** : prompt système d'abord, données variables ensuite, pour que le préfixe reste cachable.

- [ ] **Étape 1 : écrire le test qui échoue**

Le test central : le corps envoyé pour un lot contient le **texte source** des sections de ce lot, et **ne contient pas** le synopsis du sommaire ni le texte des sections d'un autre lot. Écris-le en donnant à chaque section un contenu reconnaissable.

Couvrir aussi : sans `$sections`, le comportement actuel est strictement inchangé ; une slide sans section rattachée reçoit les sections voisines.

- [ ] **Étape 2 : lancer le test, vérifier l'échec, implémenter, vérifier, committer**

---

## Task 8 — import de document et branchement

**Files:**
- Modify: `src/Controller/Slidia/SlidiaApiController.php`
- Modify: `src/Controller/Slidia/SlidiaController.php`
- Modify: `templates/slidia/create.html.twig`
- Modify: `assets/controllers/slidia/create_controller.js`
- Test: `tests/Functional/Controller/Slidia/SlidiaDocumentImportTest.php`

**Interfaces:**
- Produces : `POST /api/slidia/document` — accepte un PDF, retourne `{hash, charCount, sectionCount}`

Validation de l'import, sur le modèle de `MoodiaController::upload()` : type MIME **deviné du contenu** et non déclaré par le client, taille plafonnée à `SlidiaConfig::MAX_UPLOAD_SIZE`, stockage temporaire sous `var/uploads`. Un document au-delà du plafond de caractères est refusé avec un message indiquant le volume constaté et la limite.

`generateStream()` s'appuie sur `SlidiaDocumentService` quand la présentation porte un `sourceHash`, et sur le brief texte sinon. Le sommaire, s'il existe, alimente la phase de plan ; les sections alimentent la rédaction.

**L'interface est provisoire et le restera peu de temps.** Une zone de dépôt dans l'étape « Brief » du wizard et un interrupteur pour le mode fidèle, rien de plus : l'écran de configuration de l'étape 3 les remplacera. Ne pas y investir de soin visuel — le soin va dans le back.

L'interrupteur n'apparaît que si l'analyse a détecté une structure : un bandeau « Structure détectée : N slides, on la respecte à la lettre » avec la possibilité de revenir au mode créatif.

- [ ] **Étape 1 : écrire le test qui échoue**

Couvrir : un PDF valide renvoie une empreinte et un nombre de sections ; un fichier qui n'est pas un PDF est refusé ; un document trop volumineux est refusé avec le volume dans le message ; une requête non authentifiée est rejetée.

- [ ] **Étape 2 : lancer le test, vérifier l'échec, implémenter, vérifier, committer**

---

## Task 9 — vérification de bout en bout

**Files:** aucun (vérification seule)

- [ ] **Étape 1 : suites complètes**

```bash
php bin/phpunit tests/Unit/ --testdox
php -d memory_limit=1G bin/phpunit tests/Functional/ --testdox
```

Références au départ de 2b : 2166 unitaires, 510 fonctionnels. Aucune régression.

- [ ] **Étape 2 : linters et compilation**

```bash
php bin/console lint:container
php bin/console lint:twig templates/
npm run build
```

- [ ] **Étape 3 : Scormia intact**

```bash
git diff --name-only ca938f73..HEAD | grep -i scormia
```

Attendu : aucun résultat.

- [ ] **Étape 4 : le sommaire n'atteint jamais la rédaction**

Vérifier par lecture, et par le test de la tâche 7, que le corps envoyé en phase de rédaction contient le texte source et jamais le synopsis. **C'est la promesse de qualité de l'étape entière** — si elle tombe, tout le bénéfice de l'architecture tombe avec.

- [ ] **Étape 5 : le format persisté n'a pas changé**

L'assertion de `SlidiaGenerateStreamTest` sur `array_keys($slides[0])` doit toujours passer.

- [ ] **Étape 6 : commit final**

```bash
git commit --allow-empty -m "chore(slidia): documents et fidélité de l'étape 2b vérifiés de bout en bout"
```

---

## Ce que 2b laisse à l'étape 3

- La refonte de l'accueil Slidia, la page de choix de méthode, les filtres, la suppression des catégories et de l'épinglage.
- L'écran de configuration qui remplacera le branchement provisoire du wizard, et qui exposera le choix « Standard / Approfondi ».
- La barre de recherche de Moodia.
- Les composants Twig partagés livrés à l'étape 1, dont aucun n'a encore de consommateur en production.
