# Slidia v2 — Étape 1 : le socle

**Date :** 2026-07-31
**Branche :** `slidia_v2`
**Statut :** design validé, en attente de relecture

---

## 1. Contexte

Slidia doit être remis au niveau de Moodia et Scormia, sur deux axes : la qualité de la
génération IA et la cohérence de l'interface. Le chantier est découpé en trois étapes
séquentielles :

| Étape | Contenu | Spec |
|---|---|---|
| **1. Socle** | Composants Twig partagés, recherche/pagination en repository, couche Responses API et comptage des tokens | **ce document** |
| 2. Slidia IA | `gpt-5-mini`, réécriture des prompts, mode « format précis », import et condensation de PDF | à venir |
| 3. Slidia UI | Refonte de l'accueil, page de choix de méthode, nettoyage, recherche Moodia | à venir |

Le socle ne produit **aucun changement visible** pour l'utilisateur. Il installe les
fondations dont dépendent les étapes 2 et 3.

---

## 2. Portée

### Dans la portée

- Un trait de recherche partagé pour les repositories, et le passage de Slidia et Moodia à
  une pagination SQL.
- Cinq composants Twig extraits du markup Scormia, plus un contrôleur Stimulus de recherche.
- L'extension de `callResponsesApi()` : sorties structurées, effort de raisonnement,
  détection de troncature.
- Un accumulateur de consommation de tokens couvrant tous les appels d'une génération.
- Les tests unitaires correspondants.

### Hors portée

- **Scormia n'est pas modifié.** Son markup et son repository servent de référence ; ils
  restent en l'état. Une migration ultérieure vers les composants partagés reste possible,
  à rendu strictement identique.
- Aucun changement de modèle IA, de prompt ou d'interface : c'est l'objet des étapes 2 et 3.
- Le mode « Standard / Approfondi » et la condensation de PDF relèvent de l'étape 2 ; le
  socle se contente de rendre leur implémentation possible.

---

## 3. Décisions actées

| Sujet | Décision |
|---|---|
| Modèle Slidia | `gpt-5-mini` (étape 2), `gpt-5-nano` sur les tâches mécaniques |
| Pagination | **9 éléments par page** pour Slidia, Moodia et Scormia — valeur unique, aucune différence entre outils |
| Recherche | Côté serveur, en repository, pour Slidia comme pour Moodia |
| Accent Slidia | Ambre `#F59E0B` — inchangé |
| Décompte des tokens | Basé sur l'`usage` réel renvoyé par OpenAI à chaque appel, additionné jusqu'à la fin de la génération. Aucun coefficient par modèle. |
| Tokens de raisonnement | Décomptés |
| `entity-card` | Retenu, bien qu'un seul outil le consomme dans cette étape |
| Épinglage Slidia | Supprimé (entité, API, interface) — voir 4.2 bis |

---

## 4. Recherche, filtrage et pagination en repository

### 4.1 Le trait partagé

`ScormiaModuleRepository::applySearch()` fait déjà le travail correctement : comparaison en
`LOWER()`, échappement des métacaractères LIKE (`%`, `_`, `!`) via `ESCAPE '!'`, absence de
filtre si la chaîne est vide. Ce comportement est extrait sans modification sémantique.

**Fichier :** `src/Repository/Concern/LikeSearchTrait.php`

```php
namespace App\Repository\Concern;

use Doctrine\ORM\QueryBuilder;

/**
 * Recherche plein-texte simple, partagée par les bibliothèques d'outils.
 *
 * Les métacaractères LIKE saisis par l'utilisateur sont échappés via ESCAPE '!' :
 * sans cela, un « % » dans la recherche remonterait toute la table.
 */
trait LikeSearchTrait
{
    /**
     * @param string ...$fields Champs à interroger, préfixés de leur alias (ex. 'p.title').
     *                          Plusieurs champs sont combinés en OR.
     */
    private function applyLikeSearch(QueryBuilder $qb, ?string $search, string ...$fields): void
}
```

Le nommage du paramètre est `:search`. Le trait ne fait rien si `$search` est vide ou si
`$fields` est vide.

**Scormia n'adopte pas le trait dans cette étape** (consigne : ne rien toucher). Sa méthode
privée reste en place et produit exactement le même SQL.

### 4.2 Les repositories

`SlidiaPresentationRepository` gagne :

```php
/** @return SlidiaPresentation[] */
public function findByUserPaginated(
    User $user,
    int $offset,
    int $limit,
    ?int $templateId = null,
    ?string $search = null,
): array

public function countByUser(
    User $user,
    ?int $templateId = null,
    ?string $search = null,
): int
```

`$templateId` : `null` = tous les masques ; un entier = ce masque ; la constante
`SlidiaPresentationRepository::NO_TEMPLATE` = les présentations sans masque. La recherche
porte sur `p.title`.

`findByUser()` est conservée : elle sert à l'export et au tableau de bord.

`App\Repository\Moodia\MoodiaCourseRepository` gagne le même couple. Le travail y est plus
léger qu'attendu : `findByUserWithFilters()` construit déjà un `QueryBuilder` et exprime le
filtre de type en SQL (`c.generationType = :type`), tout comme le filtre de statut. Il suffit
donc d'en extraire la construction de requête, puis d'ajouter la recherche, l'`offset`, le
`limit` et la méthode de comptage. Cela supprime le chargement intégral suivi d'un
`array_slice()` en PHP dans `MoodiaController::index` : **le filtrage et la pagination passent
en SQL.**

`findByUserWithFilters()` est conservée pour ses appelants existants.

### 4.2 bis — Suppression de l'épinglage

Slidia affiche aujourd'hui deux sections, « Épinglés » puis « Tous les autres », sur une page
non paginée. Ce découpage devient incohérent dès qu'on pagine, et ni Moodia ni Scormia n'ont
d'équivalent.

**Décision retenue : l'épinglage est supprimé**, au même titre que les catégories.

Portée de la suppression :

- champ `pinned` de `SlidiaPresentation`, avec migration ;
- clé `pinned` acceptée par `PATCH /api/slidia/presentations/{id}` ;
- sections `pinnedSection` / `othersSection` du listing et entrée « Épingler » du menu
  contextuel (étape 3) ;
- tri : il ne reste que `ORDER BY p.updatedAt DESC`, identique à Moodia et Scormia.

Le socle se contente de ne pas réintroduire la notion dans les nouvelles méthodes de
repository. Le retrait effectif de l'entité, de l'API et de l'interface est réalisé à
l'étape 3, avec celui des catégories, dans une migration unique.

### 4.3 Pagination unifiée

Nouvelle constante `AppLimits::TOOL_LIBRARY_PER_PAGE = 9`, utilisée par Moodia et Slidia.

Supprimées :
- `AppLimits::MOODIA_COURSES_PER_PAGE` (valait déjà 9)
- `SlidiaConfig::PRESENTATIONS_PER_PAGE` (valait 30, jamais lue)

`ScormiaController::PER_PAGE` reste tel quel : sa valeur est déjà 9, le comportement est
identique. Son alignement sur la constante partagée est laissé à une passe ultérieure.

### 4.4 Les contrôleurs

Les contrôleurs lisent les paramètres de requête, les valident et délèguent. Aucune logique
de filtrage ne doit y subsister.

- Le numéro de page est borné : `max(1, min($totalPages, $page))`.
- Les compteurs d'onglets restent absolus, indépendants de la recherche — comportement
  Scormia, à reproduire à l'identique.
- Les paramètres de requête sont propagés dans les liens d'onglets et de pagination
  (motif `qp` de `scormia/index.html.twig`).

---

## 5. Composants Twig

Cinq composants, modelés sur le markup Scormia. Chacun est documenté en tête de fichier avec
le tableau de paramètres, conformément à la convention du répertoire.

### 5.1 `_organisms/tool-header/` — en-tête d'outil

Bloc Twig `actions`, donc utilisé via `{% embed %}`.

| Paramètre | Type | Défaut | Rôle |
|---|---|---|---|
| `icon` | string | requis | Nom d'atome (`bubul-slidia`…) |
| `title` | string | requis | Nom de l'outil |
| `description` | string | `null` | Phrase de présentation |
| `color` | objet | `null` | Entrée de `tool_color_details` ; si absent, les classes `primary` globales |
| `badge` | objet | `null` | `{label, variant}` — le badge `BETA` de Scormia |
| `cost` | objet | `null` | `{value, unit}` — le coût médian en tokens de Moodia |
| `primaryAction` | objet | `null` | `{label, href, icon, disabled, tooltip}` |
| `class` | string | `''` | Classes additionnelles |

Le bloc `actions` prend le pas sur `primaryAction` quand il est défini : c'est ainsi que
Slidia ajoutera « Mes masques » à côté de « Créer une présentation ».

Rendu : le `<header class="mb-8">` de Scormia, avec la pastille 16×16 en dégradé, le titre
`text-2xl sm:text-3xl font-bold font-secondary`, et le coût affiché en `hidden lg:inline-flex`
comme chez Moodia.

### 5.2 `_organisms/tool-toolbar/` — barre de filtres et de recherche

Blocs `leading` et `trailing`, donc `{% embed %}`. **Les deux sont optionnels** : c'est ce qui
permet à chaque outil d'avoir des filtres plus ou moins riches sans dupliquer la barre.

| Paramètre | Type | Défaut | Rôle |
|---|---|---|---|
| `filters` | tableau | `null` | Transmis tel quel à `filter-tabs` |
| `active` | string | `null` | Filtre actif |
| `name` | string | `'filter'` | Nom du groupe de filtres |
| `search` | objet | `null` | Transmis tel quel à `search-input` |
| `class` | string | `''` | Classes additionnelles |

Rendu : `flex flex-col md:flex-row md:items-stretch gap-3 md:gap-4 mb-6`, le bloc `leading`
en `shrink-0`, le bloc `trailing` en `flex-1 md:max-w-md md:ml-auto`.

Par défaut, `leading` rend `filter-tabs` si `filters` est fourni et `trailing` rend
`search-input` si `search` est fourni. Slidia surchargera `leading` pour y placer son filtre
de masque.

### 5.3 `_molecules/search-input/` — le champ de recherche

Extraction fidèle du champ de `scormia/index.html.twig` : `h-12 rounded-full bg-white
border border-black/[0.08]`, icône positionnée en absolu, `data-turbo-permanent`.

| Paramètre | Type | Défaut |
|---|---|---|
| `placeholder` | string | `'Rechercher…'` |
| `value` | string | `''` |
| `name` | string | `'q'` |
| `id` | string | requis (pour `data-turbo-permanent`) |
| `action` | string | `null` — valeur de `data-action` |
| `class` | string | `'flex-1 md:max-w-md md:ml-auto'` |

`relative` est posé en dur sur le conteneur, hors du paramètre `class` : c'est lui qui
ancre l'icône positionnée en absolu. Le rendre surchargeable casserait la mise en page dès
qu'un outil ajusterait la largeur du champ.

**La molécule `_molecules/search-bar/` existante n'est pas réutilisée.** Elle n'est appelée
nulle part, et elle embarque un `<script>` inline avec un identifiant aléatoire — ce qui la
rend incompatible avec la navigation Turbo. Elle est laissée en l'état (elle est référencée
par le design system d'administration) mais son usage est déconseillé ; la note est ajoutée
dans son en-tête.

### 5.4 `_organisms/choice-page/` — page de choix de méthode

Gabarit complet de la page « Créez votre cours Moodle » : icône, titre, sous-titre, grille de
`choice-card`, bouton de retour.

| Paramètre | Type | Défaut |
|---|---|---|
| `icon` | string | requis |
| `color` | objet | `null` |
| `title` | string | requis |
| `subtitle` | string | `null` |
| `choices` | tableau | requis — chaque entrée est un jeu de paramètres de `choice-card` |
| `backLabel` | string | `'Retour'` |
| `backHref` | string | requis |

La grille s'adapte au nombre d'entrées : `lg:grid-cols-3` à trois choix, `lg:grid-cols-2` à
deux. Cela permettra à Slidia de démarrer à trois méthodes sans figer le gabarit.

### 5.5 `_organisms/entity-card/` — coque de carte

Blocs `badge`, `body`, `footerLeft`, `footerRight`, `menu`, donc `{% embed %}`.

| Paramètre | Type | Défaut |
|---|---|---|
| `href` | string | requis |
| `accent` | string | `null` — couleur hexadécimale du liseré et du survol |
| `attr` | objet | `{}` — attributs de données du conteneur |
| `class` | string | `''` |

Rendu : `min-h-[320px] bg-white rounded-2xl shadow-sm border border-gray-100`, liseré
supérieur `h-1 scale-x-0 group-hover:scale-x-100`, footer `border-t border-gray-100
bg-gray-50/30`, emplacement du menu en `absolute top-3 right-3`. Ce sont les classes déjà
communes à Moodia et Scormia, aujourd'hui dupliquées.

Le composant est créé ici mais n'a encore aucun appelant : son premier consommateur sera la
carte de présentation de Slidia, à l'étape 3.

### 5.6 Documentation dans le design system

Chaque nouveau composant reçoit sa page de démonstration sous
`templates/admin/design-system/`, au même format que les composants existants : aperçu rendu,
tableau des paramètres et extrait de code. C'est la convention du projet, et c'est ce qui rend
les composants découvrables pour les développements suivants.

### 5.7 Contrôleur Stimulus `shared--library-search`

**Fichier :** `assets/controllers/shared/library_search_controller.js`

Reprend le comportement de `scormia--library#search` : anti-rebond de 300 ms, construction de
l'URL à partir de `window.location.href`, suppression du paramètre `page`, ajout ou retrait du
paramètre de recherche, puis `Turbo.visit(url, { action: 'replace' })` avec repli sur
`window.location.assign`.

| Valeur | Défaut | Rôle |
|---|---|---|
| `param` | `'q'` | Nom du paramètre de requête |
| `delay` | `300` | Anti-rebond, en millisecondes |

Branché sur Moodia et Slidia. `scormia--library` conserve sa propre implémentation.

---

## 6. Couche IA partagée

### 6.1 Le problème

`OpenAIConfig::requiresResponsesApi()` oriente tout modèle `gpt-5*` vers
`callResponsesApi()`, qui n'envoie que `model`, `input` et `max_output_tokens`. Les
paramètres `temperature`, `max_tokens` et surtout `response_format` sont silencieusement
perdus. Or les deux prompts de Slidia dépendent de `response_format: json_object`.

Changer `SLIDIA_MODEL` sans corriger ce chemin casserait le parsing JSON **sans message
d'erreur**.

Ce chemin de code n'est utilisé par aucun outil aujourd'hui (Moodia `gpt-4o-mini`, Scormia
`gpt-4.1`, Serenia `gpt-4.1-mini`, Monalisia, Castia et Pixia `gpt-4o-mini`). Il peut donc
être étendu sans aucun risque de régression.

### 6.2 Ce qui change

`chatCompletion()` conserve sa signature : aucun appelant existant n'est touché.

`callResponsesApi()` reçoit désormais `$extraParams` et les traduit :

| Entrée de l'appelant | Envoyé à la Responses API |
|---|---|
| `response_format: {type: 'json_schema', json_schema: {name, schema, strict}}` | `text: {format: {type: 'json_schema', name, schema, strict}}` |
| `response_format: {type: 'json_object'}` | `text: {format: {type: 'json_object'}}` |
| `reasoning: {effort: …}` | `reasoning: {effort: …}` |
| `verbosity: …` | `text: {verbosity: …}` |
| `prompt_cache_key: …` | `prompt_cache_key: …` |

La traduction est portée par une méthode privée dédiée,
`mapExtraParamsToResponsesApi(array $extraParams): array`, testable isolément. Les appelants
restent ainsi agnostiques du format d'API : ils décrivent ce qu'ils veulent, le client sait
comment le demander.

Les clés inconnues sont transmises telles quelles, pour ne pas bloquer un paramètre futur.

### 6.3 Détection de troncature

`max_output_tokens` couvre **à la fois** les tokens de raisonnement et ceux de la réponse. Un
raisonnement long peut donc consommer le budget et faire couper la réponse au milieu, ce qui
se manifeste par du JSON invalide plutôt que par une erreur.

`handleResponse()` vérifie donc, pour la Responses API :

- `status === 'incomplete'` → lève `TruncatedResponseException` avec la raison
  (`incomplete_details.reason`), le modèle et le nombre de tokens consommés.

L'exception `App\Exception\Slidia\TruncatedResponseException` (définie, jamais levée) est
déplacée vers **`App\Exception\Shared\AI\TruncatedResponseException`** : elle concerne la
couche partagée, pas un outil. Elle porte le modèle, la raison et l'usage constaté.

Le traitement de l'exception côté appelant relève de l'étape 2 (message d'erreur explicite et
reprise sur un lot plus petit).

### 6.4 Comptage des tokens

**Principe retenu :** on se fonde sur l'`usage` renvoyé par OpenAI à chaque appel, et on
additionne jusqu'à la fin de la génération. Aucun coefficient par modèle : un token compte
pour un token, quel que soit le modèle qui l'a produit.

**Les tokens de raisonnement sont décomptés, intégralement.** C'est acquis dès lors qu'on se
fonde sur l'`usage` de l'API, car OpenAI les inclut déjà dans `output_tokens` :

> « While reasoning tokens are not visible via the API, they still occupy space in the model's
> context window and are billed as output tokens. »
> — documentation OpenAI, *Reasoning*

Autrement dit, `output_tokens` = tokens de raisonnement + texte visible, et
`output_tokens_details.reasoning_tokens` n'est qu'une **ventilation** de ce total, fournie à
titre de transparence. Additionner cette clé au total la compterait une seconde fois et
facturerait le raisonnement en double à l'utilisateur.

La règle d'implémentation est donc explicite :

- le montant débité se calcule à partir de `total` (soit `input + output`), qui **contient**
  le raisonnement ;
- la clé `reasoning` remonte séparément dans les logs, pour savoir quelle part du coût vient
  de la réflexion et arbitrer le réglage de l'effort à l'étape 2 ;
- elle n'est **jamais** ajoutée au montant débité.

Un test unitaire verrouille ce comportement : une réponse comportant 1 000 tokens de sortie
dont 400 de raisonnement doit se facturer sur 1 000, pas 1 400.

`OpenAIResponseParser::extractUsage()` gère déjà `input`, `output`, `cached` et les deux
formats d'API. Il gagne une clé :

```php
'reasoning' => $outputDetails['reasoning_tokens'] ?? 0,
```

Cette clé sert à la ventilation dans les logs. Elle n'entre pas dans l'addition : voir
ci-dessous, le raisonnement est déjà porté par `output`.

**Nouveau : `src/Service/Shared/AI/TokenUsageAccumulator.php`**

```php
final class TokenUsageAccumulator
{
    public function record(string $model, string $step, array $usage): void;
    public function total(): int;          // somme des 'total' de chaque appel
    public function breakdown(): array;    // par étape : modèle, input, cached, output, reasoning
    public function callCount(): int;
}
```

Un accumulateur traverse toute la génération d'une présentation : condensation, plan, chaque
lot de contenu, notes. Le contrôleur ne débite qu'une fois, à la fin, à partir de
`total()`, et journalise `breakdown()`.

L'intérêt est structurel : ajouter un appel IA sans le compter devient une omission visible,
puisque chaque appel doit être enregistré pour que sa réponse soit exploitée.

Le plancher existant (`AppLimits::SLIDIA_GENERATE_MIN_TOKENS`) est conservé comme repli si
**aucun** appel de la génération n'a renvoyé d'`usage`.

Cas d'un `usage` vide sur un appel isolé, au milieu d'une génération qui en compte plusieurs :
l'appel est enregistré avec un total de 0 et journalisé en avertissement (modèle et étape
concernés). Il ne doit ni invalider les autres appels, ni déclencher le plancher : une
génération de six appels dont un seul n'a pas renvoyé d'`usage` se facture sur les cinq
autres. L'avertissement dans les logs signale l'anomalie sans pénaliser l'utilisateur.

La journalisation de `breakdown()` alimentera l'arbitrage sur l'effort de raisonnement à
l'étape 2 : sans mesure, le réglage se ferait à l'aveugle.

### 6.5 Configuration

Ajouts dans `OpenAIConfig` :

```php
public const REASONING_EFFORT_MINIMAL = 'minimal';
public const REASONING_EFFORT_LOW     = 'low';
public const REASONING_EFFORT_MEDIUM  = 'medium';
public const REASONING_EFFORT_HIGH    = 'high';
```

Aucun modèle n'est modifié dans cette étape.

---

## 7. Tests

Tests unitaires PHPUnit 12, avec `Symfony\Component\HttpClient\MockHttpClient`.

**`tests/Unit/Service/Shared/AI/OpenAIHttpClientTest.php`**
- Un modèle `gpt-4o` passe par Chat Completions, avec `temperature` et `max_tokens`.
- Un modèle `gpt-5-mini` passe par la Responses API, avec `max_output_tokens`.
- `response_format: json_schema` est traduit en `text.format`.
- `reasoning.effort` est transmis intact.
- Une réponse `status: 'incomplete'` lève `TruncatedResponseException`.
- Une clé inconnue de `extraParams` est transmise telle quelle.

**`tests/Unit/Service/Shared/AI/OpenAIResponseParserTest.php`**
- Extraction d'une réponse `output: [reasoning, message]` — l'élément de raisonnement est
  ignoré, le texte est bien celui du message.
- `extractUsage()` renvoie `reasoning` pour les deux formats d'API.

**`tests/Unit/Service/Shared/AI/TokenUsageAccumulatorTest.php`**
- Le total est la somme des appels.
- La ventilation conserve l'ordre et le modèle de chaque étape.
- Un accumulateur vide renvoie 0.
- **Les tokens de raisonnement sont comptés une fois et une seule** : un appel de 1 000
  tokens de sortie dont 400 de raisonnement compte pour 1 000, jamais 1 400.
- Un appel dont l'`usage` est vide compte 0 sans invalider les autres.

**`tests/Unit/Repository/Concern/LikeSearchTraitTest.php`**
- Les métacaractères `%`, `_` et `!` sont échappés.
- Une chaîne vide ou `null` n'ajoute aucune condition.
- Plusieurs champs produisent une condition en OR.

Les composants Twig sont couverts indirectement par les tests fonctionnels de l'étape 3.

---

## 8. Points de vigilance

1. **Le cache de prompt d'OpenAI** s'applique automatiquement aux préfixes **identiques** d'au
   moins 1 024 tokens, reste valable au moins 30 minutes, et facture l'entrée servie depuis le
   cache 0,025 $ le million au lieu de 0,25 $ pour `gpt-5-mini` — soit 90 % de remise. Deux
   conséquences pour l'implémentation :
   - **L'ordre des messages est structurant.** Le contenu stable (prompt système, puis
     description des masques, puis brief) doit précéder le contenu variable (les slides du lot
     en cours). Un seul caractère de différence en tête du prompt annule le bénéfice.
   - Le paramètre `prompt_cache_key` améliore la fiabilité de l'appariement lorsque plusieurs
     requêtes partagent un long préfixe. Il est donc transmis tel quel par le client ; l'étape
     2 le valorisera avec une clé par masque et par génération.

   **Ordre de grandeur du gain, pour ne pas se faire d'illusions :** quelques millièmes de
   dollar par présentation. Le cache est gratuit à mettre en place, donc on le fait, mais
   l'essentiel du coût est en sortie (2 $ le million), pas en entrée.
2. **La pagination change le contrat de `findByUser()`** pour Slidia. Ses appelants actuels
   (tableau de bord, métriques de badges) doivent continuer d'utiliser la méthode non
   paginée : à vérifier lors de l'implémentation.
3. **`data-turbo-permanent` sur le champ de recherche** conserve le focus et la valeur entre
   deux navigations Turbo. Il exige un `id` stable : le paramètre est obligatoire.
4. **Les compteurs d'onglets restent absolus.** C'est le comportement Scormia et il est
   volontaire : un compteur qui suivrait la recherche afficherait « Tous 0 » sur une recherche
   infructueuse, ce qui est déroutant.

---

## 9. Ce que le socle rend possible

- **Étape 2** : `gpt-5-mini` avec sorties structurées et effort de raisonnement réglable,
  comptage exhaustif de la consommation sur une génération multi-appels, détection des
  réponses tronquées.
- **Étape 3** : un en-tête, une barre d'outils, une carte et une page de choix identiques
  entre outils, une recherche serveur pour Slidia et Moodia, et une pagination unique à 9
  éléments.
