# Slidia v2 — Étape 2a : le pipeline texte — 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 :** faire passer la génération Slidia de `gpt-4o` à `gpt-5-mini` avec sorties structurées, réécrire les deux prompts, et corriger les dix défauts qui rendent le pipeline actuel fragile — au premier rang desquels une réponse vide qui produit une présentation vide facturée sans erreur.

**Architecture :** le service `SlidiaAiService` reste le seul point de contact avec l'IA, mais son contrat durcit : les réponses sont validées contre un schéma JSON avant d'être exploitées, les masques sont projetés avant envoi, et les slides sont assemblées par identifiant plutôt que par rang. `SlidiaController::generateStream()` gagne un logger, une garde de statut, une persistance au fil de l'eau et un `finally` qui laisse toujours la base dans un état cohérent. Le comptage passe par `TokenUsageAccumulator`, livré à l'étape 1.

**Tech Stack :** Symfony 7.3, PHP 8.2+, Doctrine ORM, PHPUnit 12, Stimulus 3, API OpenAI Responses.

**Spec de référence :** `docs/specs/2026-08-01-slidia-v2-ia-design.md` (sections 2, 4, 5, 8, 8 bis, 9)

## 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 ni Moodia.**
- **Le format persisté de `slidesData` est inchangé.** L'identifiant de slide introduit ici n'existe que dans la réponse du modèle : il sert à l'assemblage puis est écarté avant `setSlidesData()`. Les présentations existantes doivent rester lisibles par l'éditeur, le mode présentateur et l'export PPTX — aucun de ces trois n'est modifié.
- 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` (PHPUnit 12). Les tests fonctionnels demandent `php -d memory_limit=1G bin/phpunit tests/Functional/` — la base de test sqlite existe déjà (`var/data_test.db`).
- **Aucun test ne doit appeler l'API OpenAI.** Les appels sont simulés via un mock de `HttpClientInterface`, selon la convention déjà en place dans `tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php`.
- La base MySQL de développement n'est pas joignable : ne pas exécuter `doctrine:migrations:migrate`. `make:migration` fonctionne et suffit.

---

## Contrainte technique déterminante : le mode strict des sorties structurées

OpenAI impose trois règles à tout schéma passé avec `strict: true` :

1. chaque objet doit déclarer `"additionalProperties": false` ;
2. **tous** les champs d'un objet doivent figurer dans son tableau `required` (l'optionnalité se modélise par un type union avec `null`) ;
3. les clés d'un objet doivent être connues à l'avance.

La troisième règle a une conséquence directe sur la phase 2. Le format actuel renvoie `fields` comme un objet à clés dynamiques (`"title:"`, `"body:1"`…), ce qui est **impossible** en mode strict. Le schéma impose donc une liste :

```json
"fields": [ { "key": "body:1", "value": "…" } ]
```

`SlidiaAiService` reconvertit cette liste en objet avant de la remonter au contrôleur, de sorte que **le format persisté ne change pas**. Cette conversion est le point le plus important de la tâche 8 : c'est elle qui garantit la rétro-compatibilité.

---

## Structure des fichiers

| Fichier | Responsabilité |
|---|---|
| `src/Entity/SlidiaPresentation.php` | *(modifié)* Trois champs : profil, empreinte source, message d'erreur |
| `migrations/VersionYYYYMMDDHHMMSS.php` | *(généré)* La migration correspondante |
| `src/Service/Slidia/LayoutProjector.php` | *(créé)* Projection des masques pour l'IA et validation des chemins |
| `src/Config/Slidia/PlanSchema.php` | *(créé)* Schéma JSON de la phase 1 |
| `src/Config/Slidia/ContentSchema.php` | *(créé)* Schéma JSON de la phase 2 |
| `src/Service/Slidia/SlidiaAiService.php` | *(modifié)* Modèle, prompts, schémas, validation, assemblage |
| `src/Config/OpenAIConfig.php` | *(modifié)* Modèles et efforts Slidia |
| `src/Controller/Slidia/SlidiaController.php` | *(modifié)* Logger, garde de statut, persistance, `finally` |
| `assets/controllers/slidia/generate_controller.js` | *(modifié)* Détection de coupure et reprise |
| `templates/slidia/generate.html.twig` | *(modifié)* Bouton de reprise |

---

## Task 1 — les trois champs de présentation

**Files:**
- Modify: `src/Entity/SlidiaPresentation.php`
- Create: `migrations/Version*.php` (généré)
- Test: `tests/Unit/Entity/SlidiaPresentationTest.php`

**Interfaces:**
- Produces :
  - `SlidiaPresentation::getGenerationProfile(): string` / `setGenerationProfile(string): self`
  - `SlidiaPresentation::getSourceHash(): ?string` / `setSourceHash(?string): self`
  - `SlidiaPresentation::getErrorMessage(): ?string` / `setErrorMessage(?string): self`
  - `SlidiaConfig::PROFILE_STANDARD = 'standard'`, `SlidiaConfig::PROFILE_DEEP = 'approfondi'`

`sourceHash` n'est pas exploité par ce plan — il l'est par le plan 2b, pour le cache d'analyse de document. Il est ajouté ici pour n'avoir qu'une seule migration sur cette table.

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

Créer `tests/Unit/Entity/SlidiaPresentationTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Entity;

use App\Config\SlidiaConfig;
use App\Entity\SlidiaPresentation;
use PHPUnit\Framework\TestCase;

class SlidiaPresentationTest extends TestCase
{
    public function testLeProfilParDefautEstStandard(): void
    {
        $this->assertSame(SlidiaConfig::PROFILE_STANDARD, (new SlidiaPresentation())->getGenerationProfile());
    }

    public function testLeProfilEstModifiable(): void
    {
        $presentation = (new SlidiaPresentation())->setGenerationProfile(SlidiaConfig::PROFILE_DEEP);

        $this->assertSame('approfondi', $presentation->getGenerationProfile());
    }

    public function testLEmpreinteSourceEstNulleParDefaut(): void
    {
        $this->assertNull((new SlidiaPresentation())->getSourceHash());
    }

    public function testLeMessageDErreurEstNulParDefautEtEffacable(): void
    {
        $presentation = new SlidiaPresentation();
        $this->assertNull($presentation->getErrorMessage());

        $presentation->setErrorMessage('Réponse tronquée');
        $this->assertSame('Réponse tronquée', $presentation->getErrorMessage());

        $presentation->setErrorMessage(null);
        $this->assertNull($presentation->getErrorMessage());
    }
}
```

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

```bash
php bin/phpunit tests/Unit/Entity/SlidiaPresentationTest.php --testdox
```

Attendu : ÉCHEC — `Undefined constant App\Config\SlidiaConfig::PROFILE_STANDARD`.

- [ ] **Étape 3 : ajouter les constantes de profil**

Dans `src/Config/SlidiaConfig.php`, à la suite du bloc des statuts :

```php
    // =========================================================================
    // PROFILS DE GÉNÉRATION
    // =========================================================================

    /**
     * Profil de génération, exprimé en langage métier et non en nom de modèle.
     *
     * « standard » couvre le besoin courant. « approfondi » relève l'effort de
     * raisonnement sur la seule phase de plan : c'est là que la structure d'une
     * présentation gagne réellement, et le surcoût y reste contenu.
     */
    public const PROFILE_STANDARD = 'standard';
    public const PROFILE_DEEP     = 'approfondi';

    /** @var list<string> */
    public const PROFILES = [self::PROFILE_STANDARD, self::PROFILE_DEEP];
```

- [ ] **Étape 4 : ajouter les trois champs à l'entité**

Dans `src/Entity/SlidiaPresentation.php`, après la propriété `$energyConsumptionWh` :

```php
    /**
     * Profil de génération choisi pour cette présentation.
     * Pilote l'effort de raisonnement transmis à chaque phase.
     */
    #[ORM\Column(length: 20, options: ['default' => SlidiaConfig::PROFILE_STANDARD])]
    private string $generationProfile = SlidiaConfig::PROFILE_STANDARD;

    /**
     * Empreinte du document source, quand la présentation vient d'un fichier.
     * Sert de clé au cache d'analyse : relancer une génération sur le même
     * document ne doit ni la repayer, ni la refaire attendre.
     */
    #[ORM\Column(length: 64, nullable: true)]
    private ?string $sourceHash = null;

    /**
     * Cause du dernier échec de génération, effacée à la reprise.
     * Sans elle, une présentation en échec ne peut rien dire à son propriétaire.
     */
    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $errorMessage = null;
```

Ajouter l'import `use App\Config\SlidiaConfig;` et les six accesseurs, sur le modèle de ceux déjà présents dans le fichier (chacun retournant `self` pour les setters).

- [ ] **Étape 5 : générer la migration**

```bash
php bin/console make:migration
```

Ouvrir le fichier généré et vérifier qu'il ne contient **que** les trois colonnes ajoutées sur `slidia_presentation`. S'il contient autre chose — une table sans rapport, un index inattendu — c'est que la base de développement diverge du schéma : ne pas committer et signaler le problème.

Ne pas exécuter `doctrine:migrations:migrate`.

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

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

Attendu : les 4 nouveaux tests passent, aucune régression.

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

```bash
git add src/Entity/SlidiaPresentation.php src/Config/SlidiaConfig.php migrations/ tests/Unit/Entity/SlidiaPresentationTest.php
git commit -m "feat(slidia): profil de génération, empreinte source et message d'erreur sur la présentation"
```

---

## Task 2 — projection des masques et validation des chemins

**Files:**
- Create: `src/Service/Slidia/LayoutProjector.php`
- Test: `tests/Unit/Service/Slidia/LayoutProjectorTest.php`

**Interfaces:**
- Produces :
  - `LayoutProjector::forPlan(array $layouts): array`
  - `LayoutProjector::forContent(array $layouts): array`
  - `LayoutProjector::isKnownPath(array $layouts, string $path): bool`
  - `LayoutProjector::fallbackPath(array $layouts): ?string`

**Contexte :** les masques sont aujourd'hui envoyés bruts au modèle. Chaque placeholder transmis porte encore `x`, `y`, `cx`, `cy`, `defaultText`, `style`, `fillColor`, `gradFill`, `border`, `textInsets`, `lineSpacing` — produits par `PptxParser::parse()`. Le modèle n'exploite que le chemin, le type, le nom et une taille déduite. Le reste est un surcoût en tokens payé à **chaque** appel.

Ce service reprend et remplace `SlidiaAiService::filterPlanPlaceholders()`, `filterSystemPlaceholders()` et `cyToSize()`, qui disparaissent du service IA à la tâche 8.

Différence entre les deux projections, à conserver telle quelle : `forPlan()` **garde** les placeholders de type `pic`, pour que la phase 1 sache quels masques ont une zone image ; `forContent()` les retire, parce que l'IA ne génère jamais d'image.

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

Créer `tests/Unit/Service/Slidia/LayoutProjectorTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service\Slidia;

use App\Service\Slidia\LayoutProjector;
use PHPUnit\Framework\TestCase;

class LayoutProjectorTest extends TestCase
{
    /** Masque réaliste, tel que le produit PptxParser. */
    private function layouts(): array
    {
        return [
            [
                'id'   => 1,
                'path' => 'ppt/slideLayouts/slideLayout1.xml',
                'name' => 'Titre',
                'type' => 'title',
                'placeholders' => [
                    ['type' => 'ctrTitle', 'idx' => '', 'name' => 'Titre', 'x' => 100, 'y' => 200,
                     'cx' => 8000000, 'cy' => 800000, 'defaultText' => 'Cliquez', 'style' => ['b' => 1],
                     'fillColor' => '#FFF', 'gradFill' => null, 'border' => null,
                     'textInsets' => [1, 2, 3, 4], 'lineSpacing' => 1.2],
                    ['type' => 'dt', 'idx' => '10', 'name' => 'Date', 'cy' => 300000],
                    ['type' => 'pic', 'idx' => '2', 'name' => 'Image', 'cy' => 3000000],
                ],
            ],
            [
                'id'   => 2,
                'path' => 'ppt/slideLayouts/slideLayout2.xml',
                'name' => 'Contenu',
                'type' => 'obj',
                'placeholders' => [
                    ['type' => 'body', 'idx' => '1', 'name' => 'Corps', 'cy' => 4500000],
                ],
            ],
        ];
    }

    public function testLaProjectionNeGardeQueLesChampsUtiles(): void
    {
        $projected = (new LayoutProjector())->forPlan($this->layouts());

        $this->assertSame(['path', 'name', 'type', 'placeholders'], array_keys($projected[0]));
        $this->assertSame(['key', 'type', 'name', 'size'], array_keys($projected[0]['placeholders'][0]));
    }

    public function testLaGeometrieEtLesStylesSontEcartes(): void
    {
        $encoded = json_encode((new LayoutProjector())->forPlan($this->layouts()));

        foreach (['gradFill', 'textInsets', 'lineSpacing', 'defaultText', 'fillColor', 'border'] as $champ) {
            $this->assertStringNotContainsString($champ, $encoded);
        }
    }

    public function testLaTailleEstDeduiteDeLaHauteur(): void
    {
        $projected = (new LayoutProjector())->forPlan($this->layouts());

        // ctrTitle est toujours forcé en xs, quelle que soit sa hauteur.
        $this->assertSame('xs', $projected[0]['placeholders'][0]['size']);
        // 4 500 000 EMU dépasse le seuil lg : xl.
        $this->assertSame('xl', $projected[1]['placeholders'][0]['size']);
    }

    public function testLaCleSuitLeFormatTypeDeuxPointsIdx(): void
    {
        $projected = (new LayoutProjector())->forPlan($this->layouts());

        $this->assertSame('ctrTitle:', $projected[0]['placeholders'][0]['key']);
        $this->assertSame('body:1', $projected[1]['placeholders'][0]['key']);
    }

    public function testLesPlaceholdersSystemeSontToujoursEcartes(): void
    {
        $encoded = json_encode((new LayoutProjector())->forPlan($this->layouts()));

        $this->assertStringNotContainsString('"type":"dt"', $encoded);
    }

    public function testLePlanGardeLesZonesImageMaisPasLeContenu(): void
    {
        $projector = new LayoutProjector();

        $this->assertStringContainsString('"type":"pic"', json_encode($projector->forPlan($this->layouts())));
        $this->assertStringNotContainsString('"type":"pic"', json_encode($projector->forContent($this->layouts())));
    }

    public function testUnMasqueSansPlaceholdersNeCassePas(): void
    {
        $projected = (new LayoutProjector())->forPlan([['path' => 'a.xml', 'name' => 'A', 'type' => 'obj']]);

        $this->assertSame([], $projected[0]['placeholders']);
    }

    public function testLaReconnaissanceDesCheminsEtLeRepli(): void
    {
        $projector = new LayoutProjector();

        $this->assertTrue($projector->isKnownPath($this->layouts(), 'ppt/slideLayouts/slideLayout2.xml'));
        $this->assertFalse($projector->isKnownPath($this->layouts(), 'ppt/slideLayouts/inventé.xml'));
        $this->assertSame('ppt/slideLayouts/slideLayout1.xml', $projector->fallbackPath($this->layouts()));
        $this->assertNull($projector->fallbackPath([]));
    }
}
```

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

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

Attendu : ÉCHEC — classe introuvable.

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

Créer `src/Service/Slidia/LayoutProjector.php` :

```php
<?php

declare(strict_types=1);

namespace App\Service\Slidia;

use App\Config\SlidiaConfig;

/**
 * Réduit les masques d'un modèle .pptx à ce dont l'IA a réellement besoin.
 *
 * PptxParser produit, pour chaque zone, sa géométrie, ses couleurs, ses bordures,
 * ses marges et son interlignage. Le modèle n'exploite rien de tout cela : il lui
 * faut le chemin du masque, le type de chaque zone, son nom et un ordre de grandeur
 * de sa capacité. Envoyer le reste est un surcoût en tokens payé à chaque appel.
 */
final class LayoutProjector
{
    /**
     * Projection pour la phase 1 (plan).
     *
     * Les zones image sont conservées : le modèle doit savoir quels masques
     * comportent un visuel pour les répartir dans la présentation.
     */
    public function forPlan(array $layouts): array
    {
        return $this->project($layouts, SlidiaConfig::SYSTEM_PLACEHOLDER_TYPES);
    }

    /**
     * Projection pour la phase 2 (contenu).
     *
     * Les zones image sont retirées : l'IA ne produit que du texte, les visuels
     * sont renseignés dans l'éditeur.
     */
    public function forContent(array $layouts): array
    {
        return $this->project($layouts, SlidiaConfig::AI_EXCLUDED_PH_TYPES);
    }

    public function isKnownPath(array $layouts, string $path): bool
    {
        foreach ($layouts as $layout) {
            if (($layout['path'] ?? null) === $path) {
                return true;
            }
        }

        return false;
    }

    /** Masque de repli quand le modèle renvoie un chemin inconnu. */
    public function fallbackPath(array $layouts): ?string
    {
        return $layouts[0]['path'] ?? null;
    }

    /** @param list<string> $excludedTypes */
    private function project(array $layouts, array $excludedTypes): array
    {
        $projected = [];

        foreach ($layouts as $layout) {
            $placeholders = [];

            foreach ($layout['placeholders'] ?? [] as $placeholder) {
                $type = $placeholder['type'] ?? 'obj';

                if (in_array($type, $excludedTypes, true)) {
                    continue;
                }

                $placeholders[] = [
                    'key'  => $type . ':' . ($placeholder['idx'] ?? ''),
                    'type' => $type,
                    'name' => $placeholder['name'] ?? '',
                    'size' => $this->sizeOf($type, (int) ($placeholder['cy'] ?? 0)),
                ];
            }

            $projected[] = [
                'path'         => $layout['path'] ?? '',
                'name'         => $layout['name'] ?? '',
                'type'         => $layout['type'] ?? '',
                'placeholders' => $placeholders,
            ];
        }

        return $projected;
    }

    /**
     * Traduit une hauteur en EMU en ordre de grandeur exploitable par le prompt.
     *
     * Les titres et sous-titres sont forcés en « xs » quelle que soit la hauteur
     * de leur zone : une zone de titre haute reste un titre, pas un paragraphe.
     */
    private function sizeOf(string $type, int $cy): string
    {
        if (in_array($type, ['title', 'ctrTitle', 'subTitle'], true)) {
            return 'xs';
        }

        return match (true) {
            $cy < 900_000   => 'xs',
            $cy < 1_500_000 => 'sm',
            $cy < 2_500_000 => 'md',
            $cy < 4_000_000 => 'lg',
            default         => 'xl',
        };
    }
}
```

Note : `sizeOf()` étend le forçage `xs` au type `title`, alors que l'ancien `cyToSize()` ne le faisait que pour `subTitle` et `ctrTitle` — le prompt de contenu imposait déjà « type `title` : 3-6 mots » par ailleurs. Le comportement du prompt est donc inchangé, la règle est simplement remontée dans la donnée.

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

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

Attendu : 8 tests verts.

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

```bash
git add src/Service/Slidia/LayoutProjector.php tests/Unit/Service/Slidia/LayoutProjectorTest.php
git commit -m "feat(slidia): projection des masques pour l'IA, sans la géométrie ni les styles"
```

---

## Task 3 — les deux schémas de sortie structurée

**Files:**
- Create: `src/Config/Slidia/PlanSchema.php`
- Create: `src/Config/Slidia/ContentSchema.php`
- Test: `tests/Unit/Config/Slidia/SchemaTest.php`

**Interfaces:**
- Produces : `PlanSchema::definition(): array` et `ContentSchema::definition(): array`, chacune renvoyant la valeur du `json_schema` attendue par `OpenAIHttpClient` sous la forme `['name' => string, 'schema' => array, 'strict' => true]`.

**Rappel de la contrainte stricte** (voir l'en-tête de ce plan) : `additionalProperties: false` partout, tous les champs dans `required`, aucune clé dynamique. C'est ce qui impose la liste `fields: [{key, value}]` en phase 2 plutôt qu'un objet.

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

Créer `tests/Unit/Config/Slidia/SchemaTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Config\Slidia;

use App\Config\Slidia\ContentSchema;
use App\Config\Slidia\PlanSchema;
use PHPUnit\Framework\TestCase;

class SchemaTest extends TestCase
{
    /** Le mode strict d'OpenAI exige additionalProperties=false sur CHAQUE objet. */
    private function assertStrictObject(array $node, string $path = '$'): void
    {
        if (($node['type'] ?? null) === 'object') {
            $this->assertArrayHasKey('additionalProperties', $node, "additionalProperties manquant en $path");
            $this->assertFalse($node['additionalProperties'], "additionalProperties doit être false en $path");
            $this->assertSame(
                array_keys($node['properties'] ?? []),
                $node['required'] ?? [],
                "tous les champs doivent être requis en $path",
            );

            foreach ($node['properties'] ?? [] as $name => $child) {
                $this->assertStrictObject($child, "$path.$name");
            }
        }

        if (($node['type'] ?? null) === 'array') {
            $this->assertStrictObject($node['items'] ?? [], "$path[]");
        }
    }

    public function testLeSchemaDePlanEstStrict(): void
    {
        $definition = PlanSchema::definition();

        $this->assertTrue($definition['strict']);
        $this->assertSame('slidia_plan', $definition['name']);
        $this->assertStrictObject($definition['schema']);
    }

    public function testLeSchemaDeContenuEstStrict(): void
    {
        $definition = ContentSchema::definition();

        $this->assertTrue($definition['strict']);
        $this->assertSame('slidia_content', $definition['name']);
        $this->assertStrictObject($definition['schema']);
    }

    public function testLePlanImposeUnIdentifiantEtDesEnums(): void
    {
        $slide = PlanSchema::definition()['schema']['properties']['slides']['items'];

        $this->assertSame(['id', 'layoutPath', 'title', 'role', 'format'], array_keys($slide['properties']));
        $this->assertContains('couverture', $slide['properties']['role']['enum']);
        $this->assertContains('cta', $slide['properties']['role']['enum']);
        $this->assertContains('citation', $slide['properties']['format']['enum']);
    }

    public function testLeContenuUtiliseUneListeDeChampsEtNonUnObjet(): void
    {
        $slide = ContentSchema::definition()['schema']['properties']['slides']['items'];

        // Les clés de placeholder sont dynamiques : impossibles à modéliser en
        // objet strict, d'où la liste de paires.
        $this->assertSame('array', $slide['properties']['fields']['type']);
        $this->assertSame(
            ['key', 'value'],
            array_keys($slide['properties']['fields']['items']['properties']),
        );
        $this->assertArrayHasKey('id', $slide['properties']);
        $this->assertArrayHasKey('notes', $slide['properties']);
    }

    public function testLesDeuxSchemasSontSerialisablesEnJson(): void
    {
        $this->assertJson(json_encode(PlanSchema::definition(), JSON_THROW_ON_ERROR));
        $this->assertJson(json_encode(ContentSchema::definition(), JSON_THROW_ON_ERROR));
    }
}
```

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

```bash
php bin/phpunit tests/Unit/Config/Slidia/SchemaTest.php --testdox
```

Attendu : ÉCHEC — classes introuvables.

- [ ] **Étape 3 : écrire le schéma de plan**

Créer `src/Config/Slidia/PlanSchema.php` :

```php
<?php

declare(strict_types=1);

namespace App\Config\Slidia;

/**
 * Schéma de sortie de la phase 1 (plan structurel).
 *
 * Le format n'est plus décrit dans le prompt mais imposé par l'API : une réponse
 * hors format devient impossible, là où l'ancien pipeline se contentait d'un
 * `$data['slides'] ?? []` qui produisait silencieusement une présentation vide.
 *
 * Le champ `id` sert à l'assemblage : les slides sont recomposées par identifiant
 * et non par rang, de sorte qu'une omission du modèle ne décale plus tout le lot.
 * Il n'est jamais persisté.
 */
final class PlanSchema
{
    public const ROLES = ['couverture', 'introduction', 'transition', 'contenu', 'conclusion', 'cta'];

    public const FORMATS = ['couverture', 'transition', 'liste', 'texte', 'mixte', 'données', 'citation', 'accroche'];

    public static function definition(): array
    {
        return [
            'name'   => 'slidia_plan',
            'strict' => true,
            'schema' => [
                'type'                 => 'object',
                'additionalProperties' => false,
                'required'             => ['slides'],
                'properties'           => [
                    'slides' => [
                        'type'  => 'array',
                        'items' => [
                            'type'                 => 'object',
                            'additionalProperties' => false,
                            'required'             => ['id', 'layoutPath', 'title', 'role', 'format'],
                            'properties'           => [
                                'id'         => ['type' => 'integer', 'description' => 'Rang de la slide, à partir de 1.'],
                                'layoutPath' => ['type' => 'string', 'description' => 'Chemin exact du masque, recopié depuis l\'entrée.'],
                                'title'      => ['type' => 'string', 'description' => 'Titre de la slide, 3 à 6 mots.'],
                                'role'       => ['type' => 'string', 'enum' => self::ROLES],
                                'format'     => ['type' => 'string', 'enum' => self::FORMATS],
                            ],
                        ],
                    ],
                ],
            ],
        ];
    }
}
```

- [ ] **Étape 4 : écrire le schéma de contenu**

Créer `src/Config/Slidia/ContentSchema.php` :

```php
<?php

declare(strict_types=1);

namespace App\Config\Slidia;

/**
 * Schéma de sortie de la phase 2 (rédaction).
 *
 * `fields` est une LISTE de paires et non un objet : le mode strict d'OpenAI
 * interdit les clés dynamiques, or les clés de placeholder (« body:1 »,
 * « title: »…) dépendent du masque. SlidiaAiService reconvertit cette liste en
 * objet avant de la remonter, de sorte que le format persisté dans slidesData
 * reste strictement inchangé.
 */
final class ContentSchema
{
    public static function definition(): array
    {
        return [
            'name'   => 'slidia_content',
            'strict' => true,
            'schema' => [
                'type'                 => 'object',
                'additionalProperties' => false,
                'required'             => ['slides'],
                'properties'           => [
                    'slides' => [
                        'type'  => 'array',
                        'items' => [
                            'type'                 => 'object',
                            'additionalProperties' => false,
                            'required'             => ['id', 'fields', 'notes'],
                            'properties'           => [
                                'id'     => ['type' => 'integer', 'description' => 'Identifiant de la slide, recopié depuis le plan.'],
                                'fields' => [
                                    'type'  => 'array',
                                    'items' => [
                                        'type'                 => 'object',
                                        'additionalProperties' => false,
                                        'required'             => ['key', 'value'],
                                        'properties'           => [
                                            'key'   => ['type' => 'string', 'description' => 'Clé du placeholder, recopiée telle quelle.'],
                                            'value' => ['type' => 'string', 'description' => 'Contenu rédigé pour ce placeholder.'],
                                        ],
                                    ],
                                ],
                                'notes'  => ['type' => 'string', 'description' => 'Notes d\'intervenant, texte brut.'],
                            ],
                        ],
                    ],
                ],
            ],
        ];
    }
}
```

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

```bash
php bin/phpunit tests/Unit/Config/Slidia/SchemaTest.php --testdox
```

Attendu : 5 tests verts.

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

```bash
git add src/Config/Slidia/ tests/Unit/Config/Slidia/
git commit -m "feat(slidia): schémas de sortie structurée pour le plan et le contenu"
```

---

## Task 4 — modèles et efforts de raisonnement

**Files:**
- Modify: `src/Config/OpenAIConfig.php`
- Test: `tests/Unit/Config/OpenAIConfigTest.php` *(créer si absent)*

**Interfaces:**
- Produces :
  - `OpenAIConfig::SLIDIA_MODEL = 'gpt-5-mini'`
  - `OpenAIConfig::SLIDIA_LIGHT_MODEL = 'gpt-5-nano'`
  - `OpenAIConfig::SLIDIA_PLAN_EFFORT`, `SLIDIA_PLAN_EFFORT_DEEP`, `SLIDIA_CONTENT_EFFORT`
  - `OpenAIConfig::effortForProfile(string $profile): string`

**Attention :** `SLIDIA_PLAN_TEMPERATURE` et `SLIDIA_TEMPERATURE` sont supprimées. Vérifier qu'aucun autre appelant ne les lit :

```bash
grep -rn "SLIDIA_PLAN_TEMPERATURE\|SLIDIA_TEMPERATURE" src/ tests/
```

Attendu avant modification : uniquement `OpenAIConfig` et `SlidiaAiService`. Si un autre fichier apparaît, s'arrêter et le signaler.

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

Créer `tests/Unit/Config/OpenAIConfigTest.php` (ou ajouter à l'existant) :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Config;

use App\Config\OpenAIConfig;
use App\Config\SlidiaConfig;
use PHPUnit\Framework\TestCase;

class OpenAIConfigTest extends TestCase
{
    public function testSlidiaUtiliseUnModeleDeRaisonnement(): void
    {
        $this->assertSame('gpt-5-mini', OpenAIConfig::SLIDIA_MODEL);
        $this->assertTrue(OpenAIConfig::requiresResponsesApi(OpenAIConfig::SLIDIA_MODEL));
    }

    public function testLesTachesMecaniquesUtilisentUnModeleLeger(): void
    {
        $this->assertSame('gpt-5-nano', OpenAIConfig::SLIDIA_LIGHT_MODEL);
    }

    public function testLEffortDuPlanSuitLeProfil(): void
    {
        $this->assertSame(
            OpenAIConfig::REASONING_EFFORT_LOW,
            OpenAIConfig::effortForProfile(SlidiaConfig::PROFILE_STANDARD),
        );
        $this->assertSame(
            OpenAIConfig::REASONING_EFFORT_MEDIUM,
            OpenAIConfig::effortForProfile(SlidiaConfig::PROFILE_DEEP),
        );
    }

    public function testUnProfilInconnuRetombeSurLeStandard(): void
    {
        $this->assertSame(
            OpenAIConfig::REASONING_EFFORT_LOW,
            OpenAIConfig::effortForProfile('n-importe-quoi'),
        );
    }

    public function testLeContenuResteAuRaisonnementMinimal(): void
    {
        $this->assertSame(OpenAIConfig::REASONING_EFFORT_MINIMAL, OpenAIConfig::SLIDIA_CONTENT_EFFORT);
    }

    public function testLesTemperaturesSlidiaOntDisparu(): void
    {
        // La Responses API ne prend pas temperature : garder ces constantes
        // laisserait croire qu'elles agissent encore.
        $this->assertFalse(defined(OpenAIConfig::class . '::SLIDIA_TEMPERATURE'));
        $this->assertFalse(defined(OpenAIConfig::class . '::SLIDIA_PLAN_TEMPERATURE'));
    }
}
```

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

```bash
php bin/phpunit tests/Unit/Config/OpenAIConfigTest.php --testdox
```

Attendu : ÉCHEC — `SLIDIA_MODEL` vaut encore `gpt-4o`.

- [ ] **Étape 3 : modifier la configuration**

Dans `src/Config/OpenAIConfig.php` :

1. Remplacer la constante `SLIDIA_MODEL` et son commentaire par :

```php
    /**
     * Modèle pour Slidia (génération de présentations).
     *
     * Modèle de raisonnement : passe par la Responses API, ne prend pas
     * `temperature`, et ses tokens de réflexion sont facturés au tarif de sortie.
     * Environ dix fois moins cher que gpt-4o à qualité au moins équivalente sur
     * cette tâche.
     */
    public const SLIDIA_MODEL = 'gpt-5-mini';

    /**
     * Modèle des tâches mécaniques de Slidia : condensation de document et
     * notes d'intervenant. Cinq fois moins cher que SLIDIA_MODEL.
     */
    public const SLIDIA_LIGHT_MODEL = 'gpt-5-nano';
```

2. Supprimer `SLIDIA_PLAN_TEMPERATURE` et `SLIDIA_TEMPERATURE` avec leurs commentaires.

3. Ajouter, après le bloc des efforts de raisonnement :

```php
    /** Effort de la phase 1 (plan) selon le profil de génération. */
    public const SLIDIA_PLAN_EFFORT      = self::REASONING_EFFORT_LOW;
    public const SLIDIA_PLAN_EFFORT_DEEP = self::REASONING_EFFORT_MEDIUM;

    /**
     * Effort de la phase 2 (rédaction) : le remplissage sous contrainte de
     * longueur ne bénéficie pas de la délibération, qui coûterait au tarif
     * de sortie sans rien apporter.
     */
    public const SLIDIA_CONTENT_EFFORT = self::REASONING_EFFORT_MINIMAL;

    /** Effort de plan correspondant à un profil de génération Slidia. */
    public static function effortForProfile(string $profile): string
    {
        return $profile === SlidiaConfig::PROFILE_DEEP
            ? self::SLIDIA_PLAN_EFFORT_DEEP
            : self::SLIDIA_PLAN_EFFORT;
    }
```

Ajouter l'import `use App\Config\SlidiaConfig;` si le fichier n'en a pas.

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

```bash
php bin/phpunit tests/Unit/ --testdox
```

Attendu : les 6 nouveaux tests passent. **Les tests de `SlidiaAiService`, s'il en existe, peuvent échouer ici** — c'est normal, la tâche 8 les remet d'aplomb. Noter lesquels dans le rapport.

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

```bash
git add src/Config/OpenAIConfig.php tests/Unit/Config/OpenAIConfigTest.php
git commit -m "feat(slidia): bascule vers gpt-5-mini et efforts de raisonnement par profil"
```

---

## Task 5 — dette de l'étape 1 et plafond du brief

**Files:**
- Modify: `src/Service/Shared/AI/OpenAIHttpClient.php`
- Modify: `src/Config/AppLimits.php`
- Test: `tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php`

**Interfaces:**
- Produces : `AppLimits::SLIDIA_BRIEF_MAX_CHARS = 300000`

Trois corrections courtes, groupées parce qu'elles touchent les mêmes fichiers de socle.

**1. L'ordre du `array_merge` dans `callResponsesApi()`.** Les valeurs explicites (`model`, `input`, `max_output_tokens`) doivent primer sur `extraParams` : aujourd'hui une clé passée par erreur les écraserait silencieusement. C'était un mineur différé de l'étape 1 ; le premier appelant réel arrive maintenant.

**2. La validation de l'effort de raisonnement.** `mapExtraParamsToResponsesApi()` transmet `reasoning.effort` en aveugle. Une valeur hors des quatre constantes doit être remplacée par `REASONING_EFFORT_LOW` et journalisée : l'API rejetterait la requête entière, ce qui perdrait une génération complète pour une faute de frappe.

**3. Le plafond du brief.** `SlidiaConfig::BRIEF_MAX_WORDS` et `BRIEF_MAX_CHARS` existent mais ne sont lues nulle part ; seul le `maxlength` HTML limite la saisie, et il ne protège de rien côté serveur. Ajouter `AppLimits::SLIDIA_BRIEF_MAX_CHARS = 300000` — la borne haute des trois régimes de volume de la spec — et supprimer les deux constantes mortes de `SlidiaConfig`.

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

Ajouter à `tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php` :

```php
    public function testLesValeursExplicitesPrimentSurExtraParams(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [['type' => 'message', 'content' => [['type' => 'output_text', 'text' => 'ok']]]],
            'usage'  => ['input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2],
        ]);

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with('POST', OpenAIConfig::RESPONSES_API_URL, $this->callback(static function ($options): bool {
                return $options['json']['model'] === 'gpt-5-mini'
                    && $options['json']['max_output_tokens'] === 4096;
            }))
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            ['model' => 'modèle-pirate', 'max_output_tokens' => 1],
        );
    }

    public function testUnEffortInvalideRetombeSurUneValeurSure(): void
    {
        $response = $this->createMockResponse(200, [
            'status' => 'completed',
            'output' => [['type' => 'message', 'content' => [['type' => 'output_text', 'text' => 'ok']]]],
            'usage'  => ['input_tokens' => 1, 'output_tokens' => 1, 'total_tokens' => 2],
        ]);

        $this->logger->expects($this->once())->method('warning');

        $this->httpClient->expects($this->once())
            ->method('request')
            ->with('POST', $this->anything(), $this->callback(static fn ($options): bool =>
                ($options['json']['reasoning']['effort'] ?? null) === OpenAIConfig::REASONING_EFFORT_LOW))
            ->willReturn($response);

        $this->client->chatCompletion(
            'gpt-5-mini',
            [['role' => 'user', 'content' => 'Bonjour']],
            4096,
            0.7,
            300,
            ['reasoning' => ['effort' => 'turbo']],
        );
    }
```

*(Corriger le nom de la première méthode de test, qui ne doit pas contenir d'espace : `testLesValeursExplicitesPrimentSurExtraParams`.)*

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

```bash
php bin/phpunit tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php --testdox
```

- [ ] **Étape 3 : corriger le client HTTP**

Dans `callResponsesApi()`, inverser l'ordre du `array_merge` :

```php
        $params = array_merge(
            $this->mapExtraParamsToResponsesApi($extraParams),
            [
                'model'             => $model,
                'input'             => $messages,
                'max_output_tokens' => $maxOutputTokens,
            ],
        );
```

Dans `mapExtraParamsToResponsesApi()`, ajouter un cas pour `reasoning` avant le `default` :

```php
                case 'reasoning':
                    $effort = $value['effort'] ?? null;

                    if ($effort !== null && !in_array($effort, OpenAIConfig::REASONING_EFFORTS, true)) {
                        // Une valeur hors liste ferait rejeter la requête entière par
                        // l'API : on préfère dégrader que perdre une génération.
                        $this->logger->warning('Effort de raisonnement inconnu, repli sur « low »', [
                            'effort' => $effort,
                            'model'  => 'responses',
                        ]);
                        $value['effort'] = OpenAIConfig::REASONING_EFFORT_LOW;
                    }

                    $mapped['reasoning'] = $value;
                    break;
```

Ajouter dans `OpenAIConfig`, sous les quatre constantes d'effort :

```php
    /** @var list<string> Efforts acceptés par l'API, pour validation. */
    public const REASONING_EFFORTS = [
        self::REASONING_EFFORT_MINIMAL,
        self::REASONING_EFFORT_LOW,
        self::REASONING_EFFORT_MEDIUM,
        self::REASONING_EFFORT_HIGH,
    ];
```

- [ ] **Étape 4 : plafonner le brief**

Dans `src/Config/AppLimits.php`, à la suite du bloc Slidia :

```php
    /**
     * Volume maximal de texte accepté pour une génération Slidia.
     *
     * 300 000 caractères ≈ 150 pages. Au-delà, la génération est refusée avec un
     * message indiquant le volume constaté : un document plus gros ne produirait
     * de toute façon pas plus que les 60 slides du plafond.
     */
    public const SLIDIA_BRIEF_MAX_CHARS = 300000;
```

Supprimer `SlidiaConfig::BRIEF_MAX_WORDS` et `SlidiaConfig::BRIEF_MAX_CHARS`, après avoir vérifié qu'elles ne sont lues nulle part :

```bash
grep -rn "BRIEF_MAX_WORDS\|BRIEF_MAX_CHARS" src/ templates/ assets/ tests/
```

Attendu : uniquement leur déclaration.

Le contrôle du plafond est appliqué par le contrôleur à la tâche 9.

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

```bash
php bin/phpunit tests/Unit/ --testdox
```

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

```bash
git add src/Service/Shared/AI/OpenAIHttpClient.php src/Config/OpenAIConfig.php src/Config/AppLimits.php src/Config/SlidiaConfig.php tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php
git commit -m "fix(ai): priorité des valeurs explicites, validation de l'effort et plafond du brief"
```

---

## Task 6 — réécriture du prompt de plan

**Files:**
- Modify: `src/Service/Slidia/SlidiaAiService.php` (méthode `buildPlanPrompt()`, lignes 126-239)

**Contexte :** le prompt actuel fait 111 lignes. Tout ce qui décrit le format de sortie est désormais porté par `PlanSchema` : la section « Format de sortie » et les rappels de structure disparaissent. Les valeurs autorisées de `role` et `format` sont dans les `enum` du schéma ; le prompt n'a plus qu'à expliquer **quand** employer chacune.

**Aucune règle métier ne doit être perdue.** Avant de committer, relire l'ancien prompt ligne à ligne et cocher que chaque règle se retrouve soit dans le nouveau texte, soit dans le schéma.

- [ ] **Étape 1 : remplacer le prompt**

Remplacer intégralement le corps de `buildPlanPrompt()` par :

```php
    private function buildPlanPrompt(): string
    {
        return <<<'PROMPT'
Tu es Slidia, expert en structure de présentations PowerPoint.

À partir d'un brief et de la liste des masques disponibles, tu produis le plan : nombre de
slides, masque assigné, titre (3 à 6 mots), rôle et format. Tu ne rédiges pas le contenu.

## Fond et forme

Le fond appartient à l'utilisateur : n'invente jamais une information absente du brief.
La forme est ton travail : hiérarchie, transitions, rythme visuel.

## Structure

Détecte la hiérarchie naturelle du brief et suis-la.

Si le brief est structuré (sections, chapitres, sous-titres) :
couverture, puis introduction si le brief en contient une explicite, puis pour chaque section
une slide de transition suivie d'une slide par sous-section ou concept distinct, puis conclusion.

Si le brief est libre : construis 3 à 5 sections logiques à partir de ce qui est disponible,
avec le même enchaînement. Adapte le nombre de slides au volume réel d'information.

Une slide de transition ne se justifie que si la section compte au moins 3 slides de contenu
après elle. En deçà, enchaîne directement.

## Volume

`targetSlides` est un guide, pas une cible. La règle qui prime : un concept, une slide.
Trois aspects distincts donnent trois slides légères, jamais une slide chargée. Mieux vaut
étaler une explication sur deux slides concises que la tasser en une seule. Un brief court
donne peu de slides, et c'est normal — ne comble jamais un manque de fond par de l'invention.

## Rôles

Slide 1 : couverture. Dernière slide : conclusion, ou cta si le brief contient un appel à
l'action explicite. Têtes de section : transition. Tout le reste : contenu ou introduction.

## Formats

- couverture : titre et sous-titre, slide 1 uniquement
- transition : titre de section, corps vide ou une phrase
- liste : 4 à 7 points de structure parallèle
- texte : 3 à 5 phrases factuelles courtes
- mixte : introduction, points, clôture — le polyvalent par défaut
- données : chiffres, comparaisons, statistiques
- citation : affirmation forte ou citation encadrée
- accroche : un chiffre fort ou une question directe, très peu de texte

Une bonne présentation varie. Laisse le contenu choisir le format : un sous-sujet factuel
n'est pas forcément une liste, un chiffre marquant mérite une accroche, une comparaison
appelle le format données.

## Masques

Chaque masque de la liste doit servir au moins une fois. Si les slides de contenu sont moins
nombreuses que les masques, sers d'abord ceux qui n'ont pas encore été utilisés.

Les masques comportant une zone `pic` sont des variantes visuelles à part entière : fais-les
entrer dans la rotation comme les autres.

Attribution : un masque unique pour la couverture (privilégie ctrTitle ou subTitle), un masque
dédié réutilisé pour toutes les transitions, un masque unique pour la conclusion (cherche un
nom contenant end, conclusion, merci, thank ou fin), et pour le contenu, parcours la liste dans
l'ordre en revenant au début une fois épuisée.

N'emploie jamais le même masque de contenu plus de deux fois d'affilée. Recopie `layoutPath`
exactement tel qu'il apparaît dans l'entrée.
PROMPT;
    }
```

- [ ] **Étape 2 : vérifier qu'aucune règle n'a été perdue**

Relire l'ancien prompt via `git show HEAD:src/Service/Slidia/SlidiaAiService.php` et cocher une à une les règles suivantes dans le nouveau texte ou dans `PlanSchema` :

fond/forme · hiérarchie structurée · hiérarchie libre · règle des 3 slides pour une transition · un concept une slide · ne pas fusionner · ne pas inventer · brief court = peu de slides · rôles (6 valeurs) · attribution des rôles · formats (8 valeurs) · description de chaque format · variété visuelle · couverture complète des masques · masques `pic` en rotation · masque unique de couverture · masque dédié de transition · masque de conclusion par nom · rotation du contenu · jamais 3 fois de suite · recopie exacte de `layoutPath`.

Consigner la liste cochée dans le rapport. Toute règle sans équivalent doit être réintroduite.

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

```bash
git add src/Service/Slidia/SlidiaAiService.php
git commit -m "refactor(slidia): réécrit le prompt de plan pour un modèle de raisonnement"
```

---

## Task 7 — réécriture du prompt de contenu

**Files:**
- Modify: `src/Service/Slidia/SlidiaAiService.php` (méthode `buildContentPrompt()`, lignes 241-361)

- [ ] **Étape 1 : remplacer le prompt**

```php
    private function buildContentPrompt(): string
    {
        return <<<'PROMPT'
Tu es Slidia, expert en mise en forme de présentations PowerPoint.
Tu structures et reformules le contenu du brief. Tu ne l'inventes jamais.

## Fond et forme

Le fond vient exclusivement du brief : aucun chiffre inventé, aucun exemple fictif, aucun
argument non mentionné. La forme est ton travail : reformulation, structuration, mise en
valeur. Si une zone est grande mais que le brief manque de matière, utilise ce qui existe
plutôt que de remplir.

## Fidélité au plan

Rends une entrée par slide reçue, avec l'`id` recopié tel quel. N'en fusionne aucune, n'en
saute aucune, n'en crée aucune. Recopie chaque `key` de placeholder exactement telle qu'elle
t'est donnée : ne la déduis jamais.

## HTML

Trois balises autorisées, aucune autre : `<br><br>`, `<ul><li>…</li></ul>`, `<strong>`.
Les `<br>` vont toujours par deux. Un à trois `<strong>` par slide, sur les concepts clés,
les noms, les termes techniques et les chiffres importants.

Dans une liste, n'emploie `<strong>` que si le point suit le motif « terme : description ».
Un point court sans description reste en texte brut.

## Volume selon la taille de zone

- xs : 5 à 12 mots, aucune balise
- sm : 20 à 35 mots, 2 ou 3 idées séparées par `<br><br>`
- md : 40 à 65 mots, 3 à 5 idées
- lg : 70 à 100 mots, forme mixte obligatoire
- xl : 100 à 150 mots, contexte, développement, données, conclusion

## Structure imposée par le format

Le `format` de chaque slide est fixé par le plan. Il ne se remplace pas et ne converge pas
vers `mixte`. Deux slides consécutives ne doivent pas partager la même structure HTML.

- accroche : un chiffre ou une question en `<strong>`, puis une ou deux phrases de contexte.
  30 mots au total, aucune liste.
- texte : 3 à 5 constats séparés par `<br><br>`, une phrase chacun, verbe actif et fait
  précis. Aucune liste.
- liste : 4 à 7 points de structure grammaticale identique, chacun porteur d'une information
  distincte. Quand la liste énumère des concepts nommés, écris `<strong>Nom</strong> :
  description courte`.
- mixte : une phrase d'ouverture, `<br><br>`, une liste de 3 à 5 points, `<br><br>`, une
  phrase de clôture. Dans les points, privilégie « terme clé : description courte ».
- données : chiffres en `<strong>`, comparaisons avant/après ou haut/bas, que des faits
  mesurables. Aucune liste.
- citation : l'affirmation entre guillemets, `<br><br>`, la source ou le contexte en une ligne.
- couverture : le titre reprend le sujet en 3 à 6 mots ; le sous-titre est une phrase unique
  de 10 à 16 mots, sans balise — une promesse ou une question rhétorique.

## Par type de zone

- title : texte brut, 3 à 6 mots, aucune balise
- subTitle et ctrTitle : texte brut, une phrase de 16 mots au plus, aucune balise
- slides de rôle transition : corps d'une phrase au maximum, ou vide. Jamais de liste.

Un masque comportant plusieurs zones de corps les veut toutes remplies : répartis le contenu.

## Casse des titres

Majuscule au premier mot seulement, sauf noms propres.
« Stratégie de croissance 2025 », pas « Stratégie De Croissance 2025 ».

## Ton

Couverture : accrocheur et fidèle au sujet. Introduction : pose le contexte du brief.
Transition : le titre suffit. Contenu : reformulation claire du sous-sujet.
Conclusion : synthèse de ce qui précède, sans rien ajouter. Cta : verbe d'action et étape
concrète tirée du brief.

## Exigences de qualité

Chaque corps contient au moins un élément concret tiré du brief : un chiffre, un nom, une
date, une étape. L'ouverture d'un corps ne ressemble à celle d'aucune autre slide.

Formulations proscrites : « Il est important de noter », « En conclusion », « Comme nous
l'avons vu », « N'hésitez pas ».

## Notes d'intervenant

Texte brut, 2 à 4 phrases fluides : ce que le présentateur dit à l'oral, et la transition
vers la slide suivante.
PROMPT;
    }
```

- [ ] **Étape 2 : vérifier qu'aucune règle n'a été perdue**

Même méthode qu'à la tâche 6. Règles à retrouver : fond/forme · ne pas remplir pour remplir · intégrité du plan · recopie des `key` · zones `pic` exclues (désormais garanti par `LayoutProjector::forContent()`) · casse des titres · trois balises autorisées · `<br>` doublés · 1 à 3 `<strong>` · règle du gras dans les listes · les cinq volumes par taille · format immuable · pas deux structures identiques d'affilée · les sept structures de format · règles par type de zone · corps multiples tous remplis · ton par rôle · au moins un élément concret · ouvertures différentes · liste noire de formulations · notes en texte brut.

Consigner la liste cochée dans le rapport.

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

```bash
git add src/Service/Slidia/SlidiaAiService.php
git commit -m "refactor(slidia): réécrit le prompt de contenu pour un modèle de raisonnement"
```

---

## Task 8 — le service IA : schémas, validation et assemblage

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

**Interfaces:**
- Consumes : `LayoutProjector` (tâche 2), `PlanSchema` / `ContentSchema` (tâche 3), `OpenAIConfig::effortForProfile()` (tâche 4)
- Produces :
  - `generatePlan(string $brief, string $templateName, array $layouts, string $profile = SlidiaConfig::PROFILE_STANDARD, string $language = 'fr'): array` — retourne `['plan' => list<array{id:int, layoutPath:string, title:string, role:string, format:string}>, 'usage' => array, 'model' => string]`
  - `generateBatchContent(array $planSlides, array $allLayouts, string $brief, string $accentColor = '#6366F1', string $language = 'fr'): array` — retourne `['slides' => array<int, array{fields: array<string,string>, notes: string}>, 'usage' => array, 'model' => string]`, **indexé par `id` de slide**
  - `App\Exception\Shared\AI\EmptyResponseException`

**Changements de contrat à retenir :**
- `tokensUsed` disparaît au profit de `usage` (le tableau complet de `extractUsage()`) et `model`, pour que le contrôleur puisse alimenter `TokenUsageAccumulator`.
- `generateBatchContent()` retourne une **map indexée par `id`**, plus une liste. C'est ce qui supprime l'alignement par rang.
- Les méthodes `filterPlanPlaceholders()`, `filterSystemPlaceholders()` et `cyToSize()` sont supprimées : `LayoutProjector` les remplace.
- `computeTargetSlides()` est conservée telle quelle, et enfin testée.

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

Créer `tests/Unit/Service/Slidia/SlidiaAiServiceTest.php` :

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Service\Slidia;

use App\Config\OpenAIConfig;
use App\Config\SlidiaConfig;
use App\Exception\Shared\AI\EmptyResponseException;
use App\Service\Shared\AI\OpenAIHttpClient;
use App\Service\Slidia\LayoutProjector;
use App\Service\Slidia\SlidiaAiService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class SlidiaAiServiceTest extends TestCase
{
    private SlidiaAiService $service;
    private MockObject $openai;

    protected function setUp(): void
    {
        $this->openai  = $this->createMock(OpenAIHttpClient::class);
        $this->service = new SlidiaAiService($this->openai, new LayoutProjector());
    }

    private function layouts(): array
    {
        return [
            ['path' => 'l1.xml', 'name' => 'Titre', 'type' => 'title', 'placeholders' => [
                ['type' => 'ctrTitle', 'idx' => '', 'name' => 'T', 'cy' => 800000],
            ]],
            ['path' => 'l2.xml', 'name' => 'Contenu', 'type' => 'obj', 'placeholders' => [
                ['type' => 'body', 'idx' => '1', 'name' => 'C', 'cy' => 3000000],
            ]],
        ];
    }

    private function reply(array $payload): array
    {
        return [
            'content' => json_encode($payload, JSON_THROW_ON_ERROR),
            'usage'   => ['input' => 100, 'output' => 50, 'cached' => 0, 'reasoning' => 10, 'total' => 150],
        ];
    }

    public function testLePlanEstDemandeAvecUnSchemaStrictEtUnEffort(): void
    {
        $this->openai->expects($this->once())
            ->method('chatCompletion')
            ->with(
                OpenAIConfig::SLIDIA_MODEL,
                $this->anything(),
                $this->anything(),
                $this->anything(),
                $this->anything(),
                $this->callback(static function (array $extra): bool {
                    return ($extra['response_format']['type'] ?? null) === 'json_schema'
                        && ($extra['response_format']['json_schema']['name'] ?? null) === 'slidia_plan'
                        && ($extra['reasoning']['effort'] ?? null) === OpenAIConfig::SLIDIA_PLAN_EFFORT
                        && isset($extra['prompt_cache_key']);
                }),
            )
            ->willReturn($this->reply(['slides' => [
                ['id' => 1, 'layoutPath' => 'l1.xml', 'title' => 'Ouverture', 'role' => 'couverture', 'format' => 'couverture'],
            ]]));

        $result = $this->service->generatePlan('Un brief', 'Modèle', $this->layouts());

        $this->assertCount(1, $result['plan']);
        $this->assertSame(150, $result['usage']['total']);
        $this->assertSame(OpenAIConfig::SLIDIA_MODEL, $result['model']);
    }

    public function testLeProfilApprofondiReleveLEffortDuPlan(): void
    {
        $this->openai->expects($this->once())
            ->method('chatCompletion')
            ->with($this->anything(), $this->anything(), $this->anything(), $this->anything(), $this->anything(),
                $this->callback(static fn (array $extra): bool =>
                    ($extra['reasoning']['effort'] ?? null) === OpenAIConfig::SLIDIA_PLAN_EFFORT_DEEP))
            ->willReturn($this->reply(['slides' => [
                ['id' => 1, 'layoutPath' => 'l1.xml', 'title' => 'T', 'role' => 'couverture', 'format' => 'couverture'],
            ]]));

        $this->service->generatePlan('Un brief', 'Modèle', $this->layouts(), SlidiaConfig::PROFILE_DEEP);
    }

    public function testUnPlanVideLeveUneExceptionAuLieuDeProduireUneCoquille(): void
    {
        $this->openai->method('chatCompletion')->willReturn($this->reply(['slides' => []]));

        $this->expectException(EmptyResponseException::class);

        $this->service->generatePlan('Un brief', 'Modèle', $this->layouts());
    }

    public function testUneReponseSansCleSlidesLeveUneException(): void
    {
        $this->openai->method('chatCompletion')->willReturn($this->reply(['resultat' => []]));

        $this->expectException(EmptyResponseException::class);

        $this->service->generatePlan('Un brief', 'Modèle', $this->layouts());
    }

    public function testUnCheminDeMasqueInconnuRetombeSurLeRepli(): void
    {
        $this->openai->method('chatCompletion')->willReturn($this->reply(['slides' => [
            ['id' => 1, 'layoutPath' => 'inventé.xml', 'title' => 'T', 'role' => 'contenu', 'format' => 'mixte'],
        ]]));

        $result = $this->service->generatePlan('Un brief', 'Modèle', $this->layouts());

        $this->assertSame('l1.xml', $result['plan'][0]['layoutPath']);
    }

    public function testLaGeometrieDesMasquesNEstPasEnvoyee(): void
    {
        $layouts = $this->layouts();
        $layouts[0]['placeholders'][0]['gradFill'] = ['couleur' => '#ABCDEF'];

        $this->openai->expects($this->once())
            ->method('chatCompletion')
            ->with($this->anything(), $this->callback(static function (array $messages): bool {
                return !str_contains(json_encode($messages), 'gradFill');
            }))
            ->willReturn($this->reply(['slides' => [
                ['id' => 1, 'layoutPath' => 'l1.xml', 'title' => 'T', 'role' => 'couverture', 'format' => 'couverture'],
            ]]));

        $this->service->generatePlan('Un brief', 'Modèle', $layouts);
    }

    public function testLeContenuEstIndexeParIdentifiantEtNonParRang(): void
    {
        $this->openai->method('chatCompletion')->willReturn($this->reply(['slides' => [
            // Le modèle renvoie la slide 3 avant la 2 : l'ordre ne doit pas compter.
            ['id' => 3, 'fields' => [['key' => 'body:1', 'value' => 'Trois']], 'notes' => 'n3'],
            ['id' => 2, 'fields' => [['key' => 'body:1', 'value' => 'Deux']],  'notes' => 'n2'],
        ]]));

        $plan = [
            ['id' => 2, 'layoutPath' => 'l2.xml', 'title' => 'A', 'role' => 'contenu', 'format' => 'mixte'],
            ['id' => 3, 'layoutPath' => 'l2.xml', 'title' => 'B', 'role' => 'contenu', 'format' => 'mixte'],
        ];

        $result = $this->service->generateBatchContent($plan, $this->layouts(), 'Un brief');

        $this->assertSame('Deux',  $result['slides'][2]['fields']['body:1']);
        $this->assertSame('Trois', $result['slides'][3]['fields']['body:1']);
    }

    public function testLaListeDeChampsEstReconvertieEnObjet(): void
    {
        $this->openai->method('chatCompletion')->willReturn($this->reply(['slides' => [
            ['id' => 1, 'fields' => [
                ['key' => 'title:',  'value' => 'Un titre'],
                ['key' => 'body:1',  'value' => 'Du corps'],
            ], 'notes' => ''],
        ]]));

        $plan   = [['id' => 1, 'layoutPath' => 'l2.xml', 'title' => 'A', 'role' => 'contenu', 'format' => 'mixte']];
        $result = $this->service->generateBatchContent($plan, $this->layouts(), 'Un brief');

        // Format persisté inchangé : un objet clé => valeur, pas une liste de paires.
        $this->assertSame(['title:' => 'Un titre', 'body:1' => 'Du corps'], $result['slides'][1]['fields']);
    }

    public function testLaPhaseContenuUtiliseLEffortMinimal(): void
    {
        $this->openai->expects($this->once())
            ->method('chatCompletion')
            ->with($this->anything(), $this->anything(), $this->anything(), $this->anything(), $this->anything(),
                $this->callback(static fn (array $extra): bool =>
                    ($extra['reasoning']['effort'] ?? null) === OpenAIConfig::SLIDIA_CONTENT_EFFORT))
            ->willReturn($this->reply(['slides' => [['id' => 1, 'fields' => [], 'notes' => '']]]));

        $plan = [['id' => 1, 'layoutPath' => 'l2.xml', 'title' => 'A', 'role' => 'contenu', 'format' => 'mixte']];
        $this->service->generateBatchContent($plan, $this->layouts(), 'Un brief');
    }

    public function testUnJsonInvalideLeveUneExceptionClaire(): void
    {
        $this->openai->method('chatCompletion')->willReturn([
            'content' => 'ceci n\'est pas du JSON',
            'usage'   => ['total' => 10],
        ]);

        $this->expectException(EmptyResponseException::class);

        $this->service->generatePlan('Un brief', 'Modèle', $this->layouts());
    }

    public function testLeNombreDeSlidesViseSuitLeVolumeDuBrief(): void
    {
        $method = new \ReflectionMethod(SlidiaAiService::class, 'computeTargetSlides');

        // Brief très court : plancher à 11 (6 × 1,75 arrondi).
        $this->assertSame(11, $method->invoke($this->service, 'Trois mots ici'));
        // Brief structuré à 3 sections numérotées : la branche structurée s'applique.
        $structure = "1. Première\n2. Deuxième\n3. Troisième\n";
        $this->assertGreaterThan(11, $method->invoke($this->service, $structure));
        // Plafond à 60.
        $this->assertLessThanOrEqual(60, $method->invoke($this->service, str_repeat('mot ', 20000)));
    }
}
```

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

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

Attendu : ÉCHEC — le constructeur ne prend qu'un argument, `EmptyResponseException` n'existe pas.

- [ ] **Étape 3 : créer l'exception de réponse vide**

Créer `src/Exception/Shared/AI/EmptyResponseException.php` :

```php
<?php

declare(strict_types=1);

namespace App\Exception\Shared\AI;

/**
 * Levée quand un modèle renvoie une réponse inexploitable : JSON invalide,
 * structure attendue absente, ou contenu vide.
 *
 * Sans elle, une réponse vide produisait silencieusement une présentation vide,
 * marquée « prête » et facturée à l'utilisateur.
 */
class EmptyResponseException extends \RuntimeException
{
    public function __construct(
        private readonly string $step,
        string $reason,
        ?\Throwable $previous = null,
    ) {
        parent::__construct(
            sprintf('Réponse inexploitable du modèle à l\'étape « %s » : %s', $step, $reason),
            0,
            $previous,
        );
    }

    public function getStep(): string
    {
        return $this->step;
    }
}
```

- [ ] **Étape 4 : réécrire le service**

Dans `src/Service/Slidia/SlidiaAiService.php` :

1. Constructeur :

```php
    public function __construct(
        private readonly OpenAIHttpClient $openai,
        private readonly LayoutProjector $projector,
    ) {
    }
```

2. `generatePlan()` :

```php
    /**
     * Phase 1 : plan structurel.
     *
     * @return array{plan: list<array{id:int, layoutPath:string, title:string, role:string, format:string}>, usage: array, model: string}
     */
    public function generatePlan(
        string $brief,
        string $templateName,
        array $layouts,
        string $profile = SlidiaConfig::PROFILE_STANDARD,
        string $language = 'fr',
    ): array {
        $userMessage = json_encode([
            'topic'        => $brief,
            'templateName' => $templateName,
            'language'     => $language,
            'targetSlides' => $this->computeTargetSlides($brief),
            'layouts'      => $this->projector->forPlan($layouts),
        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

        $response = $this->openai->chatCompletion(
            model:    OpenAIConfig::SLIDIA_MODEL,
            messages: [
                // Le prompt système précède systématiquement les données variables :
                // c'est ce qui rend le préfixe cachable par OpenAI.
                ['role' => 'system', 'content' => $this->buildPlanPrompt()],
                ['role' => 'user',   'content' => $userMessage],
            ],
            maxTokens: OpenAIConfig::SLIDIA_MAX_TOKENS,
            timeout:   OpenAIConfig::SLIDIA_PLAN_TIMEOUT,
            extraParams: [
                'response_format'  => ['type' => 'json_schema', 'json_schema' => PlanSchema::definition()],
                'reasoning'        => ['effort' => OpenAIConfig::effortForProfile($profile)],
                'prompt_cache_key' => 'slidia-plan-' . substr(sha1($templateName), 0, 16),
            ],
        );

        $slides = $this->decodeSlides($response['content'] ?? '', 'plan');

        return [
            'plan'  => $this->normalizePlan($slides, $layouts),
            'usage' => $response['usage'] ?? [],
            'model' => OpenAIConfig::SLIDIA_MODEL,
        ];
    }
```

3. `generateBatchContent()` :

```php
    /**
     * Phase 2 : rédaction d'un lot de slides.
     *
     * Le résultat est indexé par identifiant de slide, jamais par rang : une
     * omission du modèle ne peut plus décaler tout le lot.
     *
     * @return array{slides: array<int, array{fields: array<string,string>, notes: string}>, usage: array, model: string}
     */
    public function generateBatchContent(
        array $planSlides,
        array $allLayouts,
        string $brief,
        string $accentColor = '#6366F1',
        string $language = 'fr',
    ): array {
        $projected = $this->projector->forContent($allLayouts);
        $byPath    = [];

        foreach ($projected as $layout) {
            $byPath[$layout['path']] = $layout;
        }

        $enriched = [];

        foreach ($planSlides as $slide) {
            $layout = $byPath[$slide['layoutPath']] ?? null;

            $enriched[] = [
                'id'           => $slide['id'],
                'title'        => $slide['title'] ?? '',
                'role'         => $slide['role'] ?? 'contenu',
                'format'       => $slide['format'] ?? 'mixte',
                'placeholders' => $layout['placeholders'] ?? [],
            ];
        }

        $userMessage = json_encode([
            'brief'       => $brief,
            'accentColor' => $accentColor,
            'language'    => $language,
            'slides'      => $enriched,
        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);

        $response = $this->openai->chatCompletion(
            model:    OpenAIConfig::SLIDIA_MODEL,
            messages: [
                ['role' => 'system', 'content' => $this->buildContentPrompt()],
                ['role' => 'user',   'content' => $userMessage],
            ],
            maxTokens: OpenAIConfig::SLIDIA_MAX_TOKENS,
            timeout:   OpenAIConfig::SLIDIA_CONTENT_TIMEOUT,
            extraParams: [
                'response_format'  => ['type' => 'json_schema', 'json_schema' => ContentSchema::definition()],
                'reasoning'        => ['effort' => OpenAIConfig::SLIDIA_CONTENT_EFFORT],
                'prompt_cache_key' => 'slidia-contenu',
            ],
        );

        $slides = $this->decodeSlides($response['content'] ?? '', 'contenu');

        return [
            'slides' => $this->indexContentById($slides),
            'usage'  => $response['usage'] ?? [],
            'model'  => OpenAIConfig::SLIDIA_MODEL,
        ];
    }
```

4. Trois méthodes privées à ajouter :

```php
    /**
     * Décode une réponse et en extrait la liste de slides.
     *
     * C'est le point où l'ancien pipeline échouait en silence : un JSON valide
     * sans clé « slides » produisait une présentation vide, marquée prête et
     * facturée. Ici, toute réponse inexploitable lève.
     */
    private function decodeSlides(string $content, string $step): array
    {
        if (trim($content) === '') {
            throw new EmptyResponseException($step, 'contenu vide');
        }

        try {
            $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new EmptyResponseException($step, 'JSON invalide', $e);
        }

        $slides = $data['slides'] ?? null;

        if (!is_array($slides) || $slides === []) {
            throw new EmptyResponseException($step, 'aucune slide dans la réponse');
        }

        return $slides;
    }

    /**
     * Valide le plan renvoyé : les chemins de masque inconnus sont remplacés par
     * un repli plutôt que de faire échouer l'export PPTX bien plus tard, sans
     * lien apparent avec la cause.
     */
    private function normalizePlan(array $slides, array $layouts): array
    {
        $fallback  = $this->projector->fallbackPath($layouts);
        $normalized = [];

        foreach ($slides as $index => $slide) {
            $path = (string) ($slide['layoutPath'] ?? '');

            if (!$this->projector->isKnownPath($layouts, $path)) {
                $path = $fallback ?? $path;
            }

            $normalized[] = [
                'id'         => (int) ($slide['id'] ?? $index + 1),
                'layoutPath' => $path,
                'title'      => (string) ($slide['title'] ?? ''),
                'role'       => (string) ($slide['role'] ?? 'contenu'),
                'format'     => (string) ($slide['format'] ?? 'mixte'),
            ];
        }

        return $normalized;
    }

    /**
     * Indexe le contenu par identifiant et reconvertit la liste de paires en
     * objet clé => valeur.
     *
     * La liste est imposée par le mode strict d'OpenAI, qui interdit les clés
     * dynamiques ; l'objet est le format persisté depuis toujours dans
     * slidesData. Cette conversion est ce qui garantit que les présentations
     * existantes restent lisibles.
     */
    private function indexContentById(array $slides): array
    {
        $indexed = [];

        foreach ($slides as $slide) {
            if (!isset($slide['id'])) {
                continue;
            }

            $fields = [];

            foreach ($slide['fields'] ?? [] as $field) {
                if (isset($field['key'])) {
                    $fields[(string) $field['key']] = (string) ($field['value'] ?? '');
                }
            }

            $indexed[(int) $slide['id']] = [
                'fields' => $fields,
                'notes'  => (string) ($slide['notes'] ?? ''),
            ];
        }

        return $indexed;
    }
```

5. Supprimer `filterPlanPlaceholders()`, `filterSystemPlaceholders()` et `cyToSize()`. Ajouter les imports nécessaires (`LayoutProjector`, `PlanSchema`, `ContentSchema`, `SlidiaConfig`, `EmptyResponseException`).

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

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

Attendu : 11 tests verts. Le conteneur doit se compiler : `LayoutProjector` est autowiré.

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

```bash
git add src/Service/Slidia/SlidiaAiService.php src/Exception/Shared/AI/EmptyResponseException.php tests/Unit/Service/Slidia/SlidiaAiServiceTest.php
git commit -m "feat(slidia): sorties structurées, validation stricte et assemblage par identifiant"
```

---

## Task 9 — le contrôleur : garde, journalisation et état cohérent

**Files:**
- Modify: `src/Controller/Slidia/SlidiaController.php`
- Test: `tests/Functional/Controller/Slidia/SlidiaGenerateStreamTest.php`

**Interfaces:**
- Consumes : le nouveau contrat de `SlidiaAiService` (tâche 8), `TokenUsageAccumulator` (étape 1)

Cette tâche corrige d'un bloc les défauts 1, 2, 3, 4, 5 et 9 du §8 bis de la spec.

Points à traiter, tous dans `generateStream()` :

1. **Garde de statut.** Avant toute chose : si le statut vaut `generating`, émettre un événement `error` explicite et sortir. Sans cela, un rechargement de page lance une seconde génération complète et facture deux fois.
2. **Logger.** Injecter `LoggerInterface` dans le constructeur. Toute exception est journalisée avec l'uuid de la présentation, l'étape et le message.
3. **Message d'erreur client.** Le client ne reçoit plus `$e->getMessage()` brut — qui peut contenir une URL d'API ou un extrait de payload — mais un message intelligible. Le détail va dans les journaux et dans `errorMessage`.
4. **`finally`.** Quel que soit le chemin, le statut doit être cohérent en sortie : `ready` si des slides existent, `draft` sinon. Jamais `generating`.
5. **Assemblage par identifiant.** Le contenu revient indexé par `id` : l'assemblage se fait par cette clé, plus par `$offset + $i`.
6. **Comptage.** Un `TokenUsageAccumulator` est instancié en début de flux (`new`, jamais injecté — il porte un état) ; chaque appel y est enregistré avec son modèle et son étape ; le débit se fait une fois, à partir de `total()`, et `breakdown()` part dans les journaux.
7. **`set_time_limit()`.** Borner explicitement le temps d'exécution en début de flux plutôt que de subir la limite PHP au hasard.

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

Créer `tests/Functional/Controller/Slidia/SlidiaGenerateStreamTest.php`. S'inspirer de `tests/Functional/Controller/Slidia/SlidiaApiGenerateImageTest.php` pour l'authentification et la création d'une présentation de test. Vérifier au minimum :

- une présentation au statut `generating` refuse une nouvelle génération et l'événement `error` est émis ;
- une présentation appartenant à un autre utilisateur renvoie 403 ou 404 ;
- après une génération dont le service lève, le statut n'est **pas** resté à `generating` et `errorMessage` est renseigné.

Le service IA est remplacé par une doublure dans le conteneur de test.

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

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

- [ ] **Étape 3 : modifier le contrôleur**

Appliquer les sept points ci-dessus. Le squelette de la boucle devient :

```php
            $accumulator = new TokenUsageAccumulator($this->logger);

            $planResult = $aiService->generatePlan(
                $brief,
                $name,
                $layouts,
                $presentation->getGenerationProfile(),
            );
            $accumulator->record($planResult['model'], 'plan', $planResult['usage']);

            $plan         = $planResult['plan'];
            $totalSlides  = count($plan);
            $totalBatches = (int) ceil($totalSlides / SlidiaConfig::AI_BATCH_SIZE);

            // Squelette indexé par identifiant de slide, dans l'ordre du plan.
            // `$positionOf` fige le rang d'affichage de chaque identifiant : le
            // client raisonne en positions, le serveur en identifiants.
            $allSlides  = [];
            $positionOf = [];
            foreach (array_values($plan) as $position => $slide) {
                $allSlides[$slide['id']]  = ['layoutPath' => $slide['layoutPath'], 'fields' => [], 'notes' => ''];
                $positionOf[$slide['id']] = $position;
            }

            foreach (array_chunk($plan, SlidiaConfig::AI_BATCH_SIZE) as $batchIndex => $batch) {
                $batchResult = $aiService->generateBatchContent($batch, $layouts, $brief, $accentColor);
                $accumulator->record($batchResult['model'], 'contenu-' . ($batchIndex + 1), $batchResult['usage']);

                foreach ($batchResult['slides'] as $id => $slide) {
                    if (!isset($allSlides[$id])) {
                        // Identifiant hors plan : on l'ignore plutôt que d'écraser une slide.
                        $this->logger->warning('Slidia : identifiant de slide hors plan', [
                            'uuid' => $presentation->getUuid(),
                            'id'   => $id,
                        ]);
                        continue;
                    }

                    $allSlides[$id] = array_merge($allSlides[$id], $slide);
                    $send('slide', ['index' => $positionOf[$id], 'slide' => $allSlides[$id]]);
                }

                // Persistance au fil de l'eau : voir tâche 10.
                $send('batch_done', ['batch' => $batchIndex + 1, 'total' => $totalBatches]);
            }
```

Le débit devient :

```php
            $tokensCharged = $accumulator->hasUsage()
                ? $accumulator->total()
                : AppLimits::SLIDIA_GENERATE_MIN_TOKENS;

            $this->logger->info('Slidia : génération terminée', [
                'uuid'      => $presentation->getUuid(),
                'slides'    => count($allSlides),
                'tokens'    => $tokensCharged,
                'appels'    => $accumulator->callCount(),
                'detail'    => $accumulator->breakdown(),
            ]);
```

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

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

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

```bash
git add src/Controller/Slidia/SlidiaController.php tests/Functional/Controller/Slidia/SlidiaGenerateStreamTest.php
git commit -m "fix(slidia): garde de statut, journalisation, état cohérent et comptage exhaustif"
```

---

## Task 10 — persistance au fil de l'eau et reprise

**Files:**
- Modify: `src/Controller/Slidia/SlidiaController.php`
- Test: `tests/Functional/Controller/Slidia/SlidiaGenerateStreamTest.php`

**Contexte :** aujourd'hui `setSlidesData()` n'est appelé qu'une fois, en toute fin. Un échec au cinquième lot sur six perd les quatre premiers, et les tokens déjà consommés chez OpenAI ne sont jamais facturés.

À livrer :

1. `setSlidesData(array_values($allSlides))` et `flush()` **après chaque lot**, pas seulement à la fin.
2. **Le plan doit être persisté**, dans un nouveau champ `planData` (json, nullable) sur `SlidiaPresentation`, avec sa migration.

   *(Correction du plan initial, qui proposait de se passer de ce champ en réutilisant `slidesData`. C'est impossible : depuis la tâche 8, l'identifiant de slide est volontairement écarté avant persistance pour préserver le format existant, et `slidesData` ne conserve donc que `layoutPath`, `fields` et `notes`. Or `generateBatchContent()` a besoin de `id`, `title`, `role` et `format` pour rédiger un lot. `slidesData` suffit à savoir **quels** lots manquent, pas à les **régénérer**. Sans `planData`, une reprise devrait relancer la phase de plan, qui produirait un plan différent — incohérent avec les slides déjà écrites.)*

   Le champ stocke le plan tel que retourné par `generatePlan()`. Il est renseigné dès que le plan est obtenu, avant la première rédaction, et effacé quand la génération aboutit — un plan conservé après succès n'a plus d'utilité et alourdirait la ligne.
3. À la reprise, les slides dont `fields` est non vide sont conservées ; seuls les lots dont toutes les slides ont un `fields` vide sont relancés. La reprise n'est possible que si `planData` est renseigné ; sinon la génération repart de la phase de plan, comme une première tentative.
4. Le débit porte uniquement sur les appels de la tentative en cours : `tokensUsed` est **additionné** au précédent, jamais remplacé.
5. La correspondance entre le plan persisté et les slides déjà écrites se fait **par position** : `planData` et `slidesData` sont deux listes de même longueur et de même ordre, la n-ième entrée de l'une décrivant la n-ième de l'autre. C'est le seul rapprochement possible puisque `slidesData` ne porte pas d'identifiant. Si les deux longueurs divergent — cas anormal — la reprise est abandonnée et la génération repart du plan, plutôt que d'assembler n'importe quoi.

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

Ajouter à `SlidiaGenerateStreamTest` : une présentation dont trois slides sur six ont déjà un `fields` non vide ne doit relancer que le lot manquant, et son `tokensUsed` final doit être la somme des deux tentatives.

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

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

- [ ] **Étape 3 : implémenter**

Appliquer les quatre points ci-dessus.

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

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

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

```bash
git add src/Controller/Slidia/SlidiaController.php tests/Functional/Controller/Slidia/SlidiaGenerateStreamTest.php
git commit -m "feat(slidia): persistance des slides au fil de l'eau et reprise des lots manquants"
```

---

## Task 11 — le client : détection de coupure et reprise

**Files:**
- Modify: `assets/controllers/slidia/generate_controller.js`
- Modify: `templates/slidia/generate.html.twig`

**Contexte :** `generate_controller.js:174` sort silencieusement de la boucle quand le flux se ferme sans événement final. L'interface reste figée sur sa barre de progression, indéfiniment, sans message. C'est le mode d'échec le plus visible pour l'utilisateur, et il est aujourd'hui invisible pour lui.

À livrer :

1. Un drapeau `#finished`, mis à `true` par `#handleDone()` et `#showError()`. Si la boucle se termine sans qu'il soit levé, afficher une erreur explicite : « La génération a été interrompue. Vos slides déjà produites ont été conservées. »
2. Un `AbortController` créé au démarrage du flux et déclenché dans `disconnect()`, pour que la boucle ne survive pas au retrait du contrôleur.
3. Un bouton « Reprendre la génération » dans l'encart d'erreur de `generate.html.twig`, à côté du retour aux présentations. Il relance le flux sur la même URL — la reprise de la tâche 10 fait le reste.
4. Retirer les deux targets mortes `debugBox` et `debugContent`, qui n'existent dans aucun template.

- [ ] **Étape 1 : implémenter**

- [ ] **Étape 2 : compiler et vérifier**

```bash
npm run build
```

Vérifier ensuite qu'aucun fichier de `public/build/` n'est stagé.

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

```bash
git add assets/controllers/slidia/generate_controller.js templates/slidia/generate.html.twig
git commit -m "fix(slidia): détecte l'interruption du flux et propose la reprise"
```

---

## Task 12 — 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
```

Attendu : aucune régression par rapport à l'état de départ (2019 unitaires, 387 fonctionnels).

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

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

- [ ] **Étape 3 : aucun outil voisin touché**

```bash
git diff --name-only main...HEAD | grep -iE "scormia|moodia"
```

Attendu : uniquement les fichiers Moodia du socle et des fixtures déjà livrés, aucun nouveau.

- [ ] **Étape 4 : plus aucune trace des constantes supprimées**

```bash
grep -rn "SLIDIA_TEMPERATURE\|SLIDIA_PLAN_TEMPERATURE\|filterPlanPlaceholders\|filterSystemPlaceholders\|cyToSize" src/ tests/
```

Attendu : aucun résultat.

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

Vérifier par lecture que `setSlidesData()` reçoit toujours une liste d'objets `{layoutPath, fields: {clé => valeur}, notes}` — et jamais l'identifiant de slide ni la liste de paires. C'est la garantie que les présentations existantes restent lisibles.

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

```bash
git commit --allow-empty -m "chore(slidia): pipeline texte de l'étape 2a vérifié de bout en bout"
```

---

## Ce que 2a laisse volontairement à 2b

- La détection de brief structuré et le mode « format précis ».
- L'extraction, la segmentation et la condensation de documents PDF.
- Le cache d'analyse par empreinte, qui exploitera `sourceHash` ajouté ici.
- Le branchement provisoire du wizard (dépôt de PDF, interrupteur du mode fidèle).
- L'exposition du profil « Standard / Approfondi » à l'utilisateur, qui relève de l'écran de configuration de l'étape 3.
