# STATE.md — état vivant de LintellO

> Ce qui est en cours / à faire maintenant. Court, mis à jour souvent.
> **Ici : les constats mesurés.** L'objectif et les décisions vivent dans les specs
> (`docs/specs/INDEX.md`), l'ordre des travaux dans `docs/ROADMAP.md`. Garder les trois étanches.
> La source de vérité historique reste `Project_Status.md`.

## Cap actuel (décidé le 2026-07-30)

**Priorité = le chatbot souverain.** C'est le moteur de revenus visé pour le lancement.
- Bêta en cours sur **dev.lintello.ai** (suit `develop`). `main` n'est **pas** utilisé — normal.
- **Prospect** : dogfooding perso, lancement commercial **ultérieur**. Parké.

### Où en est l'ordre des travaux (2026-08-19)

**Phase B en fin de course · phase C suspendue · phase D regroupée sur le nouveau serveur.**
L'ordre lui-même vit dans `docs/ROADMAP.md` ; ce qui suit n'est que l'état constaté.

**L'ordre arrêté le 2026-08-19** : finir B6 → B10 → une passe de cosmétique sur B → **D8
directement**, et le reste de D une fois le dev *et* la prod portés sur le nouveau serveur.
⏸ **Flow (B5 et B3) passe en post-lancement** : le gros chantier de B était le **Screen et
l'éditeur**, pas Flow. Le critère de lancement « mindmap fonctionnelle » tombe avec lui — la
roadmap avait écrit d'avance qu'il le fallait.

| Jalon | État au 2026-08-19 |
|---|---|
| **B2** — dossier de projet et Screen | 🟢 **construit et redessiné**, sur `featScreenDocuments`, non poussé |
| **B7** — les gabarits *(né de l'usage de B2)* | 🟢 **livré et éprouvé**, sur `featGabarits`, non poussé. **Quatre défauts d'export trouvés à l'usage le 14/08 et corrigés** — voir ci-dessous |
| **B8** — les images dans un document | 🟢 **livré**, ⚠️ **sauf le HEIC** — **dette voulue**, voir ci-dessous |
| **B6** — le projet parle au chat | 🟢 **LIVRÉ les 2026-08-17/18**, sur `featGabarits` : reprise des pièces de l'ancien silo, sommaire de projet, chute des deux plafonds, extraction Markdown, **outil de lecture (le premier tool calling du produit)**, prise sur le schéma et sur l'image, et **la décision 17 en entier** — le chat écrit dans le document ouvert, en trois étapes. ✅ **ÉPROUVÉ le 2026-08-19** — QA navigateur, les onze contrôles passés, **les deux contrôles négatifs compris** (constat du user). Voir plus bas |
| **B1** — Mermaid + coloration | 🟢 **livré le 2026-08-15**, sur `featGabarits`. Les schémas se dessinent dans les trois surfaces, le code est coloré, un schéma gardé sort dessiné dans les quatre sorties. **La voie d'export a été renversée par la mesure** — voir ci-dessous |
| **B5** — mindmap de Flow | ⏸ **post-lancement le 2026-08-19**. Non entamé, outil tranché. Le motif de son dégel du 11/08 (porter le besoin d'édition graphique) est éteint : ce besoin n'existait pas |
| **B3** — Flow Sprint 5 | ⏸ **post-lancement le 2026-08-19**. Streaming UX + optimisation des prompts — du polish. ⚠️ **Flow part au lancement dans sa forme actuelle** ; c'est son rendu graphique qui attend, pas la fonctionnalité |
| **Handoff #4** — l'éditeur du Screen *(19/08)* | 🟢 **livré**, sur `featGabarits` : l'en-tête en trois familles, **dix-sept boutons deviennent six contrôles**, le sous-menu Tableau qui ne déborde plus, le repli en trois paliers, et l'aide remise en accord avec ce qui existe. **Décision 21** de `core/dossier-projet.md`. ⚠️ Le fix qui compte au-delà du lot : **le repli se mesure sur le PANNEAU, jamais sur l'écran** — voir ci-dessous |
| **B9** — le fichier de référence devient un document | ➡️ **ABSORBÉ dans B6 le 2026-08-17** : on unifie les deux natures d'objet AVANT d'écrire le sommaire, au lieu de l'écrire contre deux natures puis de le simplifier. Le surcoût que la roadmap avait noté disparaît |
| **B10** — ce que LintellO sait faire, et comment il le dit | 🟢 **cadré et livré le 2026-08-19**, sur `featGabarits`. ⚠️ **Sauf la mémoire par utilisateur**, qui reste un formulaire — voir ci-dessous |

### 📌 Ajouter un outil — ce qui est imposé depuis le 2026-08-19

> **À lire AVANT d'ajouter un outil, un bloc de prompt ou une consigne de décision.**
> Ces règles ne sont pas des préférences : chacune vient d'un défaut mesuré pendant B, et c'est
> leur absence qui a permis au mille-feuille de repousser une deuxième fois. La version
> normative vit dans `core/dossier-projet.md`, décisions 19 et 20.

#### A. Le prompt système — il reste LA garantie

1. **Un bloc déclare une dimension, un sujet, ou les deux — jamais rien.** Un bloc muet est hors
   du mécanisme : il sort toujours, quoi qu'il contredise. `PromptCoherenceTest` le refuse.
   *Pourquoi : quatorze blocs sur vingt étaient sortis par cette porte, chacun pour une raison
   juste.*
2. **Un sujet répond à UNE question.** Découper par **décision**, jamais par émetteur.
   *Pourquoi : des sujets calqués sur les émetteurs ont laissé passer une contradiction franche
   — « n'écris pas de Mermaid » à côté de « écris-le dans un bloc ```mermaid ».*
3. **Deux blocs sur un même sujet : ou une supersession DÉCLARÉE (`remplace`), ou une condition
   d'émission qui les exclut. Jamais un pari sur l'ordre.** *Pourquoi : « il est émis après, donc
   il gagne » n'est pas un arbitrage — c'est le « dernier qui gagne » que la décision #0001
   abolit.*
4. ⚠️ **Avant de déclarer une supersession, vérifier si le bloc existant mélange des décisions.**
   *Pourquoi : `forme.document_ouvert` en portait trois ; le remplacer en bloc aurait retiré le
   plan ET la garde anti-hallucination au moment précis où le chat écrit dans un document.*
5. **Le prompt système ne porte jamais de CONTENU** — un catalogue, un plan, un titre. Jamais un
   corps. *Pourquoi : ce qui y transite est incompressible, et c'est ainsi que
   `findLastMessagesWithFiles` est né deux fois.*
6. **Unifier plutôt qu'ajouter.** Un paramètre de plus sur une signature qui en porte douze est
   l'accrétion qui fabrique le mille-feuille un étage plus bas. Voir `SurfaceOuverte`, qui en
   **remplace** deux.

#### B. Ce qui vit autour du prompt système

7. ⚠️ **La description d'un outil part dans la MÊME requête que la consigne de décision.** Les
   deux disaient la même chose en deux rédactions séparées. Ce qui a deux consommateurs vit dans
   `EnoncesPartages` — et n'y entre **qu'avec deux consommateurs réels**, un test le vérifie.
8. **Ce qui se résout chez nous ne se demande pas à un modèle** : la désignation d'une pièce, la
   cible d'une modification, ce qui est ouvert à l'écran. *Plus fiable ET moins cher — le chemin
   déterministe supprime souvent l'appel au lieu de s'y ajouter.*
9. **Ne nommer que ce qui existe**, et le vérifier par un test **contre le produit** — pas contre
   une déclaration. *Pourquoi : la description de `lire_une_piece_du_dossier` a affirmé « le
   sommaire suffit souvent » deux jours après que ce fut devenu faux.*
10. **Ce qui vient du navigateur entre encadré et borné**, jamais nu. Ce qui doit être prouvé se
    prouve en base contre le compte — un identifiant, pas une parole.

#### C. Ce qu'il faut mesurer avant de dire que c'est fait

11. ⚠️ **Toute décision confiée à un modèle entre dans `tests/Live/RefusCampagneTest`, avec son
    contrôle NÉGATIF.** Un cas positif seul ne prouve rien : une consigne rendue muette ferait
    passer tous les refus. *Pourquoi : la lecture à la demande n'a JAMAIS été appelée en
    production pendant deux jours, et rien ne l'a signalé.*
12. **Un refus est un résultat.** Nommer ce qu'on n'a pas trouvé **avant** toute proposition, et
    ne jamais donner un mode d'emploi pour faire à la main ce que le produit sait faire.
13. **Rejouer la campagne live avant ET après** toute retouche d'un texte de consigne ou d'une
    description d'outil. Le risque n'y est pas le bug, c'est la **dilution**.
14. ⚠️ **Une matrice sans collision ne prouve pas la cohérence** : le mécanisme attrape les
    collisions de même sujet, pas les contradictions entre sujets voisins. Le garde-fou reste la
    règle 2.

### B10 est livré — le 19 août

**Le cadrage du user a renversé l'ordre.** B10 n'est pas d'abord « faire parler LintellO mieux » :
c'est **défaire le mille-feuille d'instructions que la phase B a reconstruit** — la même maladie
déjà soignée une fois, revenue parce que six jalons livrés à la suite ont chacun ajouté leur
couche sans regarder les autres. Et le prompt système reste **la garantie de fonctionnement** :
on n'en sort rien, on y fait rentrer proprement ce qui était dehors.

**L'inventaire d'abord** : ~37 émetteurs d'instructions en cinq familles — 20 blocs du prompt
système, 7 appels dédiés, 3 descriptions d'outils, ~7 textes injectés comme données mais qui
instruisent, et 3 restes d'avant la refonte (`MistralService`, sortis en dette séparée).

#### ⚠️ La cause structurelle, et elle disculpe la discipline

`PromptBlock` posait **deux questions dans un seul champ**. *Qui a autorité* — `level`, qui
marche. *De quoi ça parle* — `dimension`, sauf que `PromptDimension` n'est pas un enum de sujets :
c'est un enum de **préférences que l'utilisateur peut arbitrer**.

Déclarer une dimension donnait donc l'exclusivité **et** exposait à être éteint par un niveau plus
bas. Un bloc disant « un document est ouvert » a besoin de l'exclusivité mais ne doit pas pouvoir
être tu par un pré-prompt de mode : il renonçait à la dimension, et perdait l'exclusivité.
**Quatorze blocs sur vingt**, chacun pour une raison juste écrite dans son docblock.

**Le mille-feuille n'était pas un relâchement de discipline : c'était la seule sortie que le
mécanisme laissait.**

#### Ce qui a été livré

| | |
|---|---|
| **Le mécanisme** | un troisième axe `sujet`, la supersession **déclarée** (`remplace`), les collisions **enregistrées** et une matrice de tests qui les refuse |
| **L'invariant** | tout bloc déclare une dimension, un sujet, ou les deux — **jamais rien**. C'est ce qui empêchera un outil ajouté demain de se glisser hors du mécanisme |
| **Deux collisions tranchées** | la langue Mermaid séparée du placement ; la connaissance du document séparée de l'écriture dedans |
| **`EnoncesPartages`** | ce qui se disait en **trois rédactions dans la même requête** ne s'écrit plus qu'une fois |
| **`SurfaceOuverte`** | le chat sait enfin qu'une page est ouverte à côté de lui |

#### ⚠️ Trois leçons de méthode, qui valent plus que le mécanisme

- **Découper les sujets par DÉCISION, jamais par émetteur.** Mes premiers sujets suivaient les
  émetteurs : la matrice n'a alors **rien vu** d'une contradiction franche entre le bloc du schéma
  ouvert (« n'écris pas de Mermaid ») et celui du placement (« écris-le dans un bloc ```mermaid »),
  sur le tour le plus ordinaire qui soit. Trouvé **en mesurant**, pas en relisant.
- **Chercher si deux blocs mélangent des décisions AVANT de déclarer une supersession.**
  `forme.document_ouvert` en portait trois. Le remplacer en bloc aurait retiré le plan **et** la
  garde anti-hallucination sur le tour précis où le chat vient d'écrire dans le document de
  quelqu'un — on aurait échangé une contradiction contre une perte de garde-fou. Une fois séparés,
  il n'y avait plus rien à arbitrer : la collision venait d'une **condition d'émission fausse**.
- ⚠️ **Une matrice vide ne prouve pas la cohérence.** Le mécanisme attrape les collisions de même
  sujet, pas les contradictions entre sujets voisins.

#### Le pari sur l'ordre, retiré

Un commentaire écrit le 18/08 disait que le bloc de modification était « émis APRÈS le plan, et
c'est ce qui règle leur conflit ». **Ce n'était pas un arbitrage, c'était un pari sur l'ordre de
lecture du modèle** — c'est-à-dire le « dernier qui gagne » que la décision #0001 existe pour
abolir. Un test le figeait même comme une propriété voulue ; il a été remplacé par son contraire.

#### Ce que « le chat ne sait pas où il est » voulait vraiment dire

⚠️ **Le tuyau évident était vide** : l'outil courant se dérive de l'URL, mais on ne peut taper dans
le chat que depuis la route du chat. Le signal aurait valu « chat » à peu près toujours.

Le vrai « où » est **à côté** du chat : le Screen porte **cinq** natures, deux seulement
remontaient. Une page rapatriée d'une recherche s'affichait sans que le modèle le sache — *« résume-moi
ça »* tombait dans le vide. `SurfaceOuverte` porte le titre et l'adresse, **jamais le corps**, et
le prompt dit au modèle qu'il ne l'a pas lue.

**Mesuré, campagne des refus, 5/5** : il l'avoue sur les trois questions qui portent sur la page,
et répond normalement sur les deux autres **sans jamais la mentionner**.

#### ⚠️ Ce qui reste ouvert, et qui est la seconde cause du ton générique

**La mémoire par utilisateur n'existe pas.** `UserContext` porte prénom, métier, secteur, niveau,
style, objectifs, adresse de lettre : un **formulaire déclaré une fois**, jamais enrichi par
l'usage. Le modèle sait *qui l'utilisateur dit être*, jamais *ce qui s'est passé entre eux*. Non
traité par ce lot.

#### Un incident, et sa leçon

⚠️ **`ChatController.php` a été vidé** en cours de route — j'ai écrit le `null` d'un
`preg_replace` qui avait échoué. Détecté à la ligne suivante (`wc -l` rendait 0), restauré par
`git checkout`, aucune perte. **`php -l` ne dit RIEN d'un fichier vide** : le lint ne protège pas
de ça.

| **QA au fil de l'eau** | 🟢 **faite au fur et à mesure, correctifs compris** (constat du user, 2026-08-17). Ce n'est plus la dette du lot — thème sombre, mobile et états vides ont été repris à mesure que les écrans sortaient, ce que la roadmap demandait précisément de ne pas laisser s'accumuler |

### Handoff #4 — l'éditeur du Screen, le 19 août, 13 commits

**Dix-sept boutons deviennent six contrôles.** `Style ▾` (ce qui STRUCTURE a un mot, ce qui décore
reste une icône), `Liste ▾`, `Insérer ▾`, et l'historique détaché à droite par un filet. Chaque
entrée de `Style ▾` est rendue dans **sa** taille et **sa** graisse : on choisit un titre en voyant
à quoi il ressemble, pas en lisant son numéro. Décision 21 de `core/dossier-projet.md`.

#### ⚠️ Le repli se mesure sur le PANNEAU, jamais sur l'écran

La première réponse au « aucun plan de repli » du rapport était des `@media` sur le viewport. Elle
était fausse, et le motif vaut au-delà de ce lot : **le panneau n'occupe pas une fraction fixe de
l'écran.** Le Screen partage 60/40, bascule en recouvrement plein cadre sous 1160 px, et
l'utilisateur le redimensionne. Un même écran de 1440 px donne donc un panneau de **860 px ou de
470 px** selon le moment — une règle sur le viewport se trompe la moitié du temps, **et en
silence**. C'est exactement ainsi que le sous-menu Tableau débordait.

La coque se mesure elle-même (`ResizeObserver`, **pas** un écouteur de `resize` : le panneau change
de largeur sans que la fenêtre bouge) et pose son palier en `data-palier`.

| Palier | Largeur | Ce qui tombe |
|---|---|---|
| 1 | ≥ 780 px | rien |
| 2 | 560–780 px | Gabarit et Exporter perdent leur mot |
| 3 | < 560 px | Liste, Insérer et Tableau passent en icônes |

⚠️ **Deux valeurs ne tombent à aucun palier** : « Demander à LintellO », parce qu'une **capacité**
doit rester lisible plus longtemps qu'un paramètre — c'est le seul point d'entrée découvrable vers
le chat ; et la valeur de `Style ▾`, parce qu'on peut oublier ce qu'ouvre un bouton mais pas
travailler sans savoir si l'on écrit dans un titre. Et le repli rend le **palier 1** tant que rien
n'est mesuré : un libellé de trop se voit, un libellé manquant ne se remarque pas.

#### ⚠️ Deux gardes-fous qui n'en étaient pas — à connaître pour tout le projet

- **`npx tsc --noEmit` NE VÉRIFIE RIEN ici.** Le `tsconfig.json` racine est un fichier de
  références (`"files": []`) : la commande sort `0` sur un fichier cassé. Constaté en laissant une
  constante et un composant **à l'intérieur d'un docblock** — tsc satisfait, code mort. C'est
  **`npm run build`** (qui lance `tsc -b`) qui contrôle réellement, et c'est lui qui a trouvé les
  quatre identifiants manquants. Il avait servi de garde-fou toute la journée ; il n'en était pas un.
- **Un CSS orphelin ne se voit ni aux tests ni au build.** `EditorToolbar.css` n'était **importé
  nulle part** — créé par un `cat >>`, jamais relié. Les trois menus rendaient donc avec le style
  par défaut du navigateur (`border: outset 2px`, fond `rgb(240,240,240)`, 20 px), d'où leur aspect
  « select ». ⚠️ **Le correctif précédent n'avait rien changé** : il ajoutait des règles à un
  fichier mort — on discutait de teintes pendant que le user voyait un `<button>` nu. Les règles
  ont rejoint `DocumentViewer.css`, où vit déjà le reste de la barre : **deux fichiers pour une
  même rangée, c'est la porte ouverte à ce qu'un seul des deux soit chargé.** Balayage fait, c'était
  le seul orphelin de `frontend/src`.

### B6 est livré — les 17 et 18 août, 18 commits

**Ce que le chat sait faire d'un dossier de projet**, et qui n'existait pas avant :

| | |
|---|---|
| **La reprise** | les pièces de l'ancien silo `ProjectFile` entrent dans le Drive. Une commande, pas une migration : contenus chiffrés des deux côtés, et une image demande d'écrire un fichier sur le disque |
| **Le sommaire** | le catalogue part par le socle, les **résumés** par l'allocateur de budget |
| **Les plafonds** | 50 000 caractères **et** 10 fichiers par projet : les deux tombent |
| **L'extraction** | un Word déposé sort en Markdown, niveaux de titre compris (critère 19) |
| **L'outil de lecture** | le **premier tool calling du produit** — vérifié sur l'API réelle |
| **La prise** | sur le schéma (serveur + navigateur) et sur l'image (description OCR puis vision) |

**1110 tests back · 393 front** (1008 et 378 au départ). Une migration MySQL de plus.

#### Ce que le sommaire a changé, et qui compte plus que le sommaire

⚠️ **`projet.fichiers` versait le texte intégral de chaque fichier dans le prompt système**, donc
dans les *incompressibles* : jamais arbitré, jamais compacté, à chaque tour. C'était
`findLastMessagesWithFiles` — le vrai poste de coût du produit, retiré au lot 5a — réinstallé un
cran plus haut, exactement là où la contrainte de la spec l'annonçait.

⚠️ **Et il n'était couvert par AUCUN test.** Vérifié : le mot n'apparaissait nulle part dans
`tests/`. Le poste de coût le plus lourd du produit n'était tenu par rien.

Le catalogue peut rester dans le socle — borné, sans contenu. Les résumés, non : vingt fois plus
légers mais **de même nature**, ils feraient revenir le mur à cent pièces. Ce qui les autorise à
céder, c'est le catalogue : sans un seul résumé, le modèle sait encore ce que le dossier contient.

#### L'outil de lecture — la forme retenue n'est pas celle que la spec décrivait

La décision 15 annonçait « un aller-retour **au milieu** » du tour. Il a lieu **avant** : un appel
`small` dédié décide s'il faut ouvrir une pièce, puis le tour part normalement en streaming.

Ce que ça évite : gérer des `tool_calls` dans un flux SSE — accumuler des fragments d'arguments
JSON répartis sur plusieurs deltas, interrompre le flux, le reprendre — sur le chemin le plus
critique du produit. Et faire payer la réponse deux fois.
⚠️ **Ce que ça coûte** : la décision de lire se prend **avant** d'avoir commencé à répondre.

**Effet de bord heureux** : la lecture ayant lieu avant `construire()`, ce qu'elle rend **traverse
le budget**. La crainte principale de la décision 15 ne se réalise pas.

**Vérifié sur l'API réelle** (`mistral-small-latest`) et pas seulement en tests doublés : le modèle
rend bien des `tool_calls`, **ses arguments arrivent en JSON dans une chaîne** et non en objet, et
il cesse d'appeler l'outil une fois le contenu reçu. 215 tokens d'entrée, 54 de sortie.

#### Deux découvertes de la reprise

- **Une image revient entière** : son `extractedText` porte l'image complète en base64, jamais
  tronquée. Le fichier se réécrit sur le disque et redevient un `DocumentImage`.
- ⚠️ **`FileService::isImageBase64()` rend FAUX dès que l'OCR a réussi** — elle teste que la chaîne
  *commence* par `[IMAGE_BASE64]`, or le texte est `[OCR]…[FIN_OCR]` **puis** le préfixe. S'y fier
  aurait écrit des mégaoctets de base64 dans le Drive. **La détection se fait sur le type MIME.**

### Ce que le premier usage réel a trouvé (2026-08-18)

**Six défauts, tous vus à l'écran, aucun par relecture.** Deux d'entre eux étaient de moi, et un
était une perte de données que j'avais introduite la veille.

| Ce qui se voyait | La cause |
|---|---|
| Écran blanc à la création d'un projet | **ancien bundle contre nouvelle API** : l'ancien `ProjectFilesManager` lisait `totalContentLength`, retiré avec les plafonds |
| Écran blanc au clic sur « Éditer » | **rebuild pendant les tests** : le morceau paresseux `DocumentEditor` n'existait plus, le serveur répondait `index.html`, le navigateur refusait un module en `text/html` |
| « Voir tous les modes » sans style | la classe était dans le JSX, **la règle nulle part** |
| Tableaux illisibles, défilement énorme | `<br>` non interprété **et** `white-space: nowrap` : une cellule de 330 caractères devenait une colonne de ~2 000 px dans un panneau de 700 |
| `<br>` toujours affiché dans le chat | l'élément HAST était fabriqué **sans `properties`** — `react-markdown` le lit sans se protéger et ne rend rien |
| `[hardBreak]` dans le document | **le correctif du `<br>` détruisait les sauts à l'enregistrement** |

#### ⚠️ Le repli `[nomDuNoeud]` — le mécanisme à connaître

`tiptap-markdown` renvoie tout nœud qu'il ne sait pas sérialiser vers une écriture HTML, laquelle
**refuse de travailler en `html: false`** et écrit `[nomDuNoeud]` dans le document. C'est ce qui a
produit `[table]` chez le user, et ce que le correctif du `<br>` a reproduit avec `[hardBreak]`.

**Un document fait un aller-retour Markdown à chaque enregistrement** : ce que le sérialiseur ne
sait pas écrire est détruit, sans erreur et sans trace.

D'où une **paire** d'extensions — `SautDeLigneEcrit` à la lecture, `SautDeLigneDansUnTableau` à
l'écriture — dont les docblocks disent que l'une sans l'autre corrompt. Et un test qui surveille le
motif générique `[nomDuNoeud]` : il en produira d'autres.

⚠️ **`[table]` n'est PAS reproduit.** Le tableau du user passe l'aller-retour dans les deux
configurations. Il manque un ingrédient — probablement une manipulation *dans* l'éditeur. La cause
reste ouverte.

⚠️ **Le miroir d'aller-retour avait divergé de l'éditeur**, une extension ayant été ajoutée d'un
seul côté. Son docblock dit pourtant « ce test doit rester le miroir exact ». Remis d'aplomb.

#### Le `<br>` : quatre surfaces, quatre comportements

Une cellule de tableau Markdown **ne peut pas contenir de saut de ligne** : `<br>` est la seule
façon standard de l'exprimer. Le modèle a raison d'en produire — c'est du GFM correct, **pas une
bizarrerie à corriger dans le socle**.

L'éditeur et le chat l'affichaient en toutes lettres ; le PDF et le DOCX le **supprimaient en
silence**, ce qui est pire. Le commentaire de l'export disait l'hypothèse à voix haute — *« rien
n'en produit »* — et elle était fausse.

⚠️ **Le vrai piège était ailleurs** : l'extension GFM embarque `DisallowedRawHtml`, qui pose son
propre rendu de `HtmlInline` **à la priorité 50**. Le nôtre, enregistré à 10, était accepté sans un
mot et **jamais appelé**. Trouvé en comparant le rendu avec et sans GFM.

#### ⚠️ Un déploiement casse les onglets ouverts

Les assets sont servis `immutable, 1 an` et `index.html` en `no-store` : qui a l'application
ouverte garde son `index.html` et **perd ses morceaux paresseux**. Il ne le saura qu'au clic sur une
route chargée à la demande, parfois des heures après.

Atténué : un morceau qui ne charge plus **rejoue une fois**, puis affiche *« une nouvelle version
est disponible »* avec un bouton. ⚠️ **Il ne recharge jamais tout seul** — une boucle de
rechargement ferait perdre à chaque tour ce que l'utilisateur écrivait.

### 📌 CINQ gestes au déploiement, pas un

> Elles étaient trois le 18/08. Deux commandes de reprise se sont ajoutées depuis, et **les deux
> réparent des données existantes** : les oublier ne casse rien visiblement, ça laisse
> simplement des documents diminués que personne ne va rechercher.

1. **Les migrations MySQL** — les cinq des 10-11 août, `document` et ses trois colonnes,
   `document_image`, et celle du 17/08 (`resume`, `lecture_seule`, `repris_le`).
   ⚠️ **Ne pas se fier au compte écrit ici — il a dérivé.** Ce point disait « dix » ; le dépôt
   porte **treize** fichiers `Version2026081*` (mesuré le 19/08), dont **huit** ne sont pas encore
   sur `origin/develop`. On ne peut pas trancher depuis le dépôt lequel des deux comptes est juste,
   parce que ce qui compte n'est pas ce qui est *poussé* mais ce qui est *appliqué* : les cinq des
   10-11 août sont poussées **sans avoir été déployées** (aucun déploiement depuis le début de B).
   **Le seul chiffre qui fait foi est `doctrine:migrations:status` sur la cible** — le lire avant,
   pas compter d'ici.
2. **`php bin/console app:projet:reprendre-les-fichiers`**, une fois, après les migrations.
   ⚠️ **Sans elle, les projets existants perdent leur contexte** : le prompt système ne lit plus
   `project_file` et les documents n'existent pas encore. Idempotente, `--blanc` pour voir d'abord.
3. **`php bin/console app:documents:resumer`** — les pièces déposées avant le 18/08 n'ont aucun
   résumé, et `DocumentContext::resume()` retombe alors sur un extrait de 600 caractères coupé au
   milieu d'un mot, que le modèle prend pour la pièce entière. Bornée à 20 par défaut,
   `--a-blanc`, n'écrase jamais un résumé existant.
4. **`php bin/console app:documents:reparer-les-sauts`** — rend leurs `<br>` aux documents où le
   correctif incomplet du 18/08 a écrit `[hardBreak]`. ⚠️ **Un document sur huit était touché en
   local, 25 sauts détruits** ; rien ne dit que la prod soit épargnée, et rien ne le signalera.
   `--a-blanc` d'abord. Idempotente.
5. **Rebuild du front, et prévenir que les onglets ouverts doivent être rechargés.**

⚠️ ~~**Et une dépendance de prod qui manque** : `OCR_REMOTE_URL` est vide, la description d'image
ne marchera pas sur le mutualisé.~~ — **FAUX, corrigé par le user le 2026-08-18** : *« OCR
fonctionne sur le mutualisé, ce sont nos appels depuis la console qui sont bloqués »*.

⚠️ **Et l'erreur vaut mieux que le fait, parce qu'elle se répète.** C'est le mur du déploiement
Prospect du 08/07, à l'identique : **OVH bride le TCP sortant externe en SSH, mais pas en cron ni
en web**. Une vérification depuis la console rend donc un **faux négatif**, et on en conclut que
la fonctionnalité est morte alors que c'est la sonde qui l'est. Deux fois le même piège en six
semaines : avant d'écrire ici qu'une dépendance externe manque en prod, vérifier par le chemin
**web ou cron**, jamais par SSH.

Reste vrai, et non vérifiable d'ici : `.env` suivi porte `OCR_REMOTE_URL=` vide, mais la vraie
valeur vit dans `.env.local`, gitignoré. On ne peut donc rien affirmer sur la prod depuis le dépôt.

### ✅ B6 est éprouvé — QA navigateur des 18 et 19 août

**Constat du user, 2026-08-19 : « B6 est bouclé ».** La section qui vit ici disait, au 18/08,
*« aucun chemin neuf de B6 n'a été exercé en conditions réelles »* — vérifié alors dans les logs et
en base : aucune trace de lecture à la demande, zéro image décrite sur trois, aucune extraction
Word/ODT. **Ce n'est plus vrai.** Les onze contrôles qui étaient listés ici et sous la décision 17
ont été passés en QA navigateur.

⚠️ **Les deux contrôles NÉGATIFS passent** — confirmés explicitement par le user le 19/08, et ce
sont les seuls que ce suivi tenait pour comptant double :

| Contrôle négatif | Ce qu'il prouve |
|---|---|
| Une question générale dans un projet garni **ne déclenche pas** de lecture | La consigne de lecture n'est pas trop faible — le produit ne paie pas un appel `small` à chaque tour |
| Une question ordinaire avec un document ouvert **ne modifie rien** | La consigne de `ModificationALaDemande` n'est pas trop faible — le seul chemin qui *écrit* dans un document sans geste ne se déclenche pas tout seul |

**Pourquoi cette insistance mérite d'être gardée.** Un cas positif seul ne prouve rien sur une
décision confiée à un modèle : il montre que la consigne *peut* déclencher, jamais qu'elle ne
déclenche pas à tort. C'est la règle inscrite dans le contrat d'ajout d'un outil, et c'est le seul
endroit du produit où l'échec ne se voit pas — un tour qui lit pour rien coûte sans rien casser,
un tour qui écrit pour rien abîme un document de l'utilisateur.

⚠️ **Ce que la QA ne couvre pas, et qui reste ouvert** :

- **`[table]` n'est toujours pas reproduit** — cause ouverte, voir plus haut. Ce n'est pas un des
  onze contrôles : c'est une **perte de données silencieuse** à l'aller-retour Markdown, et elle
  survit à B6.
- **Les cinq documents repris portent le texte plat de l'ancien extracteur** — convertis avant le
  correctif Markdown. Le nouveau rendu ne vaut que pour les dépôts à venir.

### La décision 17 — écrite ET implémentée le 18/08, en trois commits

**Le chat modifie le document sans geste, quand il sait nommer sa cible.** En révision de la
décision 7, et le motif est l'usage : *« j'ai plutôt tendance à me servir du chat pour modifier le
doc, pas de l'outil car je ne sais pas qu'il existe »* — dit par celui qui a **commandé** le geste.

⚠️ **Le retournement** : le geste ne servait pas à *autoriser*, il servait à **localiser**. Le chat
avait toujours le quoi et le avec quoi ; il lui manquait l'adresse.

Les trois étapes prévues sont faites, dans l'ordre annoncé :

| Étape | Ce qui a été construit |
|---|---|
| **1 — réparer le silence** | un bloc `forme.document_ouvert`, émis dès qu'un document est ouvert **et** que le tour n'est pas dirigé. Il dit les deux sorties : sélectionner le passage (« Demander à LintellO »), ou le coller |
| **2 — le plan du document** | `SommaireDuDocument` rend les titres avec leur **rang** et leur **niveau**. Le tour ne recevait que l'identifiant |
| **3 — l'outil** | `modifier_le_document_ouvert(cible, remplacement)`, résolu par `CibleDuDocument`, appliqué par l'éditeur |

#### Trois choix d'implémentation qui ne sont pas dans la décision

⚠️ **Le plan part par le PROMPT SYSTÈME, pas par le message** — alors que ce suivi avait écrit
« ses titres partent avec le message ». Motif : le message composé est **persisté**
(`TurnRecorder::recordUserMessage`). Un plan poussé dedans s'afficherait dans le fil, y resterait,
et repartirait en historique à chaque tour — la maladie de `findLastMessagesWithFiles`, en plus
petit. Le passage d'une prise, lui, a une raison d'être persisté (arbitrage du 15/08) ; un plan
n'en a aucune. Un test fige la route dans les deux sens.

⚠️ **Le serveur n'écrit PAS le document.** Il rend le couple `ancien` / `nouveau` ; c'est
l'éditeur qui applique, en une transaction `setContent` — vérifié dans `@tiptap/core` : un
`tr.replaceWith` unique, donc `Ctrl+Z` défait d'un coup, ce que la contrainte exige. Écrire en
base ouvrirait une course avec l'enregistrement différé de TipTap.

⚠️ **Pas de reconnaissance lexicale pour déclencher l'étape 1.** Le vocabulaire de la
modification de contenu est celui du chat ordinaire, et le cas réel du 18/08 (« le tableau des
viandes a disparu ») n'en contient aucun mot. Un faux négatif ramène le silence, c'est-à-dire le
bug. Le bloc part donc dès qu'un document est ouvert.

#### La normalisation, tranchée par le user le 18/08

**Espaces et apostrophes, rien d'autre.** Une comparaison stricte échouerait sur les deux écarts
systématiques entre le Markdown stocké et ce qui traverse TipTap. La casse et les accents, eux,
**portent du sens** : deux sections peuvent s'en distinguer, et confondre deux cibles est le
risque contre lequel le comptage des correspondances existe.

Deux implémentations jumelles — `CibleDuDocument::normaliser()` en PHP, `modificationStore.ts`
côté navigateur — parce que les deux comparent le même texte à deux moments : le serveur contre la
version enregistrée, l'éditeur contre celle qu'il vient de resérialiser. Une divergence rendrait
des modifications correctes inapplicables.

#### Ce que ça coûte, mesuré

| | Coût |
|---|---|
| Bloc du plan, document de 8 titres | ~460 tokens de prompt |
| Idem, pire cas (40 titres, borne) | ~1 000 tokens |
| L'outil de modification | **un appel `small` de plus** par tour avec un document ouvert |

⚠️ **Un tour peut désormais payer DEUX appels avant sa réponse** : la lecture à la demande
(dossier garni) et la modification (document ouvert). C'est la conséquence directe du choix hérité
de la décision 15 — ne pas mettre de `tool_calls` dans le flux SSE. À relire quand
`ModelDetectionConfig` sera rebranché ; c'est le même sujet.

#### Deux bugs trouvés par les tests, corrigés des deux côtés

La fin du document était **avalée** quand la cible fermait le texte normalisé : le rognage retire
les espaces de queue de la carte d'index, et prendre la longueur totale emportait le saut de ligne
final. Chaque modification de dernière section aurait grignoté la fin du fichier, tour après tour.
Test de non-régression posé en PHP et en TS.

#### ⚠️ Ce qui n'est PAS couvert

- **Une seule modification par tour.** Plusieurs appels d'outil : seul le premier est retenu, et
  c'est journalisé. La deuxième cible aurait été résolue contre un contenu déjà périmé.
- **Flow reçoit le plan mais pas l'outil.** Une étape intermédiaire ne doit pas modifier le
  document sous une hypothèse que la suivante démentira.
- **Un schéma est refusé** par l'outil : sa source demande `mermaid.parse` avant application, ce
  qui vit sur le chemin du geste (`PriseDuSchema`).
- **Aucun tour réel.** Voir le contrôle à faire ci-dessous.

#### ✅ Les cinq contrôles — passés le 2026-08-19

C'est un outil qui **écrit dans un document**. Le contrôle négatif valait donc autant que le
positif, et **le n°2 est confirmé passé par le user** (voir « B6 est éprouvé » plus haut) :

1. le cas d'origine — un document avec une section « Viandes », et *« le tableau des viandes a
   disparu, tu peux le réintégrer ? »*. Le document doit changer sous les yeux, le fil doit le
   dire sans recopier le texte, `Ctrl+Z` doit tout défaire d'un coup ;
2. **le contrôle négatif** : une question ordinaire avec le même document ouvert ne doit **rien**
   modifier. S'il écrit à chaque tour, la consigne de `ModificationALaDemande` est trop faible ;
3. une cible introuvable — il doit le dire et proposer une des deux sorties, sans rien toucher ;
4. deux sections du même nom — il doit demander laquelle ;
5. le bandeau d'échec : taper dans l'éditeur pendant que le tour part, puis vérifier que
   l'application refuse et le signale.

### B6 — la prise, livrée en deux moitiés (2026-08-15/16)

**Modifier un document depuis le chat marche**, sur deux objets : le **texte** et la
**forme**. Specs : `core/dossier-projet.md` (décision 7) et `core/gabarits.md` (16).

**Le texte — geste explicite.** Sélection dans l'éditeur → sixième entrée du menu bulle
(le O de LintellO, pas une étoile magique) → la consigne s'écrit dans le chat → le passage
est remplacé, annulable par `Ctrl+Z` d'un seul coup.

**La forme — reconnaissance.** « Mets les titres en rouge » dans le chat standard, un
document ouvert dans le Screen : le modèle traduit la phrase en **paramètres validés**, le
fil dit ce qui a changé en français, et « Rendre sa forme d'origine » défait.

⚠️ **Le geste explicite a été retiré POUR LA FORME après usage.** Il avait été posé par
symétrie avec le texte ; le user a compté trois portes pour modifier un document et a
tranché. La règle qui en sort vaut pour la suite : **le risque décide de la porte** — un
faux positif sur la forme coûte une couleur, sur un texte il coûte des mots.

**Deux mesures qui ont changé le code** :

- chaque prise partait en `large` avec ~2 300 tokens d'entrée pour en rendre 27. Une
  *retouche* (≤ 600 caractères, sans titre) part désormais en `small` et sans le fil ;
- ⚠️ **et ces 2 300 tokens n'étaient PAS l'historique** — j'avais annoncé le contraire :
  c'est le **prompt système** (socle 1 536 + contrat 269). Le vrai levier de coût d'un tour
  dirigé est là, et il n'est **pas** touché : un socle réduit pour ces tours toucherait aux
  décisions #0001/#0003, donc au user.

**Reste ouvert sur B6** : le sommaire de projet, la lecture à la demande (le seul morceau
qui demande vraiment du tool calling), et la prise sur le **schéma** et l'**image**.

### B1 — livré, et la voie d'export renversée par la mesure (2026-08-15)

**Les schémas se dessinent** dans le chat, dans le Screen et dans l'éditeur ; le
code est coloré des deux côtés, avec la même liste de grammaires. Un schéma
gardé **sort dessiné** en PDF, DOCX et ODT, et le Markdown rend sa source.
Spec : `docs/specs/core/schemas.md` · Décision : #0009, **révisée**.

**La première voie d'export a été livrée puis retirée en 24 h.** Elle gardait la
source dans le document et faisait retrouver à l'export un dessin déposé à part,
par empreinte. Mesure au lendemain :

```
SELECT COUNT(*) FROM rendu_de_schema  →  0
```

Deux de ses quatre maillons cassaient, **tous deux en silence** :

- **`drawImage()` échoue sans rien dire sur un SVG sans dimensions** — bug
  Mozilla 700533, ouvert depuis 2011. Mermaid produit exactement ça
  (`width="100%"` + `viewBox`). Chrome s'en accommode, **Firefox rend zéro**.
  Le défaut n'existait donc que sur certains navigateurs : il aurait été
  « corrigé » par quelqu'un travaillant sous Chrome ;
- **l'empreinte se calculait dans deux langages**, et une divergence d'une
  espace suffisait à ce que l'export ne retrouve jamais le rendu.

**Ce que le user a tranché**, et il avait raison dès sa première intuition : un
schéma gardé est **une image**, sa source rangée dans la même ligne
(`document_image.source_schema`). L'export devient celui des illustrations,
éprouvé la veille. `NULL` distingue une photo d'un schéma, et c'est tout ce qui
les distingue — ce qui rend enfin cohérent le filtre « Visuels » du Drive, qui
les mélangeait déjà.

⚠️ **Ce que l'argument d'origine avait de faux** : « la source doit rester dans
le document pour que B6 puisse la modifier ». B6 a besoin que la source soit
**retrouvable**, pas qu'elle soit dans le corps. La justification confondait
« accessible » et « stocké là ».

⚠️ **Les documents d'avant le 15/08** portent encore un bloc ```` ```mermaid ````
écrit : ils s'affichent dessinés, et **ressortent en code à l'export**. C'est
l'état d'avant, puisque la mécanique qui devait les dessiner n'a jamais marché.
Refaire « Ouvrir le schéma » suffit.

**Cinq défauts silencieux trouvés en chemin, tous à l'usage** — l'extraction qui
mangeait la clôture d'un bloc Mermaid, le Screen figé sur un message
« B1 arrive », le menu « Éditer » caché par le formulaire de saisie,
`stopEvent` manquant, et **la sérialisation qui collait à une image ce qui la
suivait** (un titre cessait d'être un titre à l'enregistrement suivant).

⚠️ **Le dernier dit quelque chose sur les tests** : le miroir d'aller-retour
montait `Image` là où l'éditeur monte `ImageAuthentifiee`. Il était juste sur le
schéma et **faux sur la sérialisation** — exactement là où vivait le défaut. Un
miroir approximatif ne protège que de ce qu'on n'a pas oublié.

**Trois migrations MySQL de plus** (dont deux qui s'annulent : la table de la
voie abandonnée est créée puis retirée — elle n'a jamais été déployée).

### Ce que le premier usage réel de l'export a trouvé (2026-08-14)

**Un compte rendu exporté en ODT et en PDF, ouvert dans un vrai traitement de texte.** Quatre
défauts, dont trois invisibles côté DOCX — c'est la raison pour laquelle la suite de tests ne les
voyait pas : elle vérifiait l'arbre PhpWord, qui était juste. **Ils étaient dans les writers.**
Diagnostic fait dans le XML des fichiers livrés, pas déduit du code.

| Ce qui se voyait | La cause, mesurée |
|---|---|
| Les listes numérotées sortaient toutes en **« % »** | Le writer ODT n'écrit jamais qu'une puce et y verse le gabarit de numéro du format Word : `text:bullet-char="%1."`, dont le lecteur ne garde que le premier caractère |
| Un `:` seul à la marge, hors de la liste | Le writer ODT met **chaque marque de mise en forme dans son propre paragraphe** — `- **Matériel** :` sortait en deux. `Writer\ODText\Element\Container` oublie `ListItemRun` dans la liste que son homologue Word2007 tient à jour |
| Puces `o` au 2ᵉ niveau, **rien** au 1ᵉʳ | Les puces d'origine sont celles de Word 2003 : caractère privé de la police `Symbol`, qui ne survit pas à l'ODT (`text:bullet-char=""`) |
| L'image du PDF débordait de la page | **Aucune règle CSS ne visait l'image** : dompdf la dessinait à sa taille en pixels, **1200 pt sur une colonne qui en fait 453** |

**Trois choses à retenir, au-delà des correctifs :**

- **La numérotation du DOCX était fausse aussi, et personne ne l'avait vu.** Toutes les listes
  numérotées d'un document partageaient un compteur : la deuxième repartait à `3.`. Les numéros
  sont désormais **calculés et écrits**, comme ceux des titres. Le prix est connu — la
  renumérotation vivante de Word est perdue ; elle numérotait faux.
- **Le centrage d'une image se décide dans le paragraphe, pas sur l'image.** `margin: auto` ne la
  déplace pas d'un point sous dompdf. Une image seule dans son paragraphe est marquée comme
  **figure** avant le rendu — l'aperçu écrit déjà cette règle en `p:has(> img:only-child)`, que
  dompdf ne sait pas lire.
- **La taille d'un visuel vit maintenant dans `GabaritParametres`**, lue par les trois sorties, et
  bornée **dans les deux sens** (une capture haute sortait par le bas). Vérifié : le même visuel
  fait 453,5 × 340,2 pt en PDF comme en bureautique.

⚠️ **Ce que cette séance dit de la méthode.** Une sortie bureautique ne se vérifie pas sur l'arbre
qu'on construit : elle se vérifie **dans le fichier produit**, décompressé. Trois des quatre
défauts vivaient entre l'arbre et le disque. Le DOCX correct donnait de surcroît une fausse
assurance sur l'ODT, alors que les deux writers ne se ressemblent pas.

⏸ **Le HEIC — dette voulue, à régler quand tout sera sur le même serveur** (arbitrage du user,
2026-08-14). GD ne le décode pas, ImageMagick oui : l'extension est sur le VPS, pas dans le
conteneur PHP. **Le geste appartient au regroupement** — il est inscrit au provisioning de **D8**,
et l'ouvrir avant reviendrait à équiper une machine qu'on s'apprête à quitter. Le code
l'utilise **dès qu'elle est là** (sas d'entrée, rien à recoder), et le refus donne en attendant le
geste qui débloque. ⚠️ **Conséquence assumée en attendant : une photo prise avec un iPhone est
refusée**, et c'est le format par défaut. À rouvrir **au moment du regroupement**, pas plus tard —
c'est la première chose que fera un utilisateur avec un téléphone.

⚠️ **Pas de déploiement tant que B n'est pas terminé** (arbitrage du user, 2026-08-12).

**Livré les 10 et 11 août, fusionné dans `develop` et poussé** (17 commits, 712 tests back +
55 front, cinq migrations MySQL en attente de déploiement) :

| Jalon | Ce qui a été livré |
|---|---|
| **D3** | Journal d'erreurs. La cause n'était ni la table ni le listener : **la résilience du produit avait mangé son journal** — le chat et Flow rattrapaient leurs pannes sans laisser de trace, et 281 erreurs étaient renvoyées à la main sans jamais lever d'exception |
| **D2** | Sécurité inscription, 13 tâches sur 14. Modération des comptes, double opt-in à blocage immédiat, domaines jetables, plafond par adresse visée. **Turnstile écarté** : depuis le double opt-in, un bot n'obtient que des comptes inertes |
| **D9** | Sonde `/api/health`. Trois états, un seul rend 503 — pour qu'une alerte reste une alerte |
| **C1** | Crédits de recherche (1 = chat, 2 = moteur, 0,0015 €), grille arbitrée sur 50 % de marge en pire cas, `plan_quota` enfin **lue**, Flow mesurable, imputation du coût par source, solde visible |
| **D5 · D6** | Validés par le user |

**Trois défauts trouvés en chemin, tous invisibles à l'usage :**

- **aucun compte du plan Gratuit ne pouvait démarrer une conversation** — la garde commerciale
  des projets bloquait aussi le conteneur système « Conversations rapides ». Invisible depuis
  tout compte de développement, qui a un plan payant ;
- la **fiche utilisateur de l'admin était en panne** : un DQL sur `l.user`, champ que `UsageLog`
  ne porte pas ;
- **Flow imputait au quota Large** de l'utilisateur les appels Small de ses étapes. Un Étudiant
  épuisait ses 100 000 tokens avec des appels qui n'en étaient pas.

⚠️ **Rien de tout cela n'a été exercé en conditions réelles.** Six jalons livrés, aucun déployé
au moment où ces lignes sont écrites.

### Ce que le premier test de charge réel a trouvé (2026-08-15)

**Quatre référentiels Bac Pro déposés dans un projet neuf, tous refusés.** Le user testait le
produit à sa charge d'usage habituelle. L'extraction avait parfaitement fonctionné : c'est un
plafond qui refusait, et le creuser a montré une couture, pas un réglage. Décisions prises dans
`docs/specs/core/dossier-projet.md` (13 et 14), lot ouvert en **B9** — ⚠️ **absorbé dans B6 le 2026-08-17 et livré**, voir plus haut.

**Le refus, mesuré :** `MAX_TOTAL_CONTENT_LENGTH = 50 000` caractères, **total pour le projet**,
**identique pour tous les plans**, vérifié dans `ProjectFileController::upload()`. Les quatre
fichiers pèsent 64 243, 124 296, 92 773 et 109 607 caractères — **390 919 au total**. Chacun dépasse
seul le plafond, d'où le `Actuel: 0` répété dans les quatre messages : rien ne s'accumule.

**Il y a trois silos de fichiers, et seul le plus ancien alimente le contexte de projet :**

| Silo | Va dans le contexte | Dans le Drive | Résumé |
|---|---|---|---|
| `ConversationFile` | **en résumé**, arbitré sous budget | non | ✅ **oui** |
| `Document` | pas du tout | ✅ oui | non |
| `ProjectFile` | **en texte intégral, incompressible** | ❌ **non** | ❌ **non** |

**Le mécanisme cherché existe déjà — pour les pièces jointes de chat.** `FileService` écrit un
résumé à l'ingestion via `generateFileSummary`, `ConversationFile` porte le champ, et
`DocumentContext::anciens()` préfère déjà `summary` au texte. `ProjectFile` a été écrit sans rien de
tout ça : son texte part par le **prompt système** (`PromptAssembler::projet()`), donc dans les
**incompressibles** de `TurnPayloadBuilder` — jamais arbitré, jamais compacté, à chaque tour.

⚠️ **C'est `findLastMessagesWithFiles` réinstallé un cran plus haut**, deux mois après son retrait
au lot 5a. Et la contrainte de la spec l'avait annoncé mot pour mot : *« un dossier de projet est
exactement la situation qui le ferait renaître »*. **Elle est née.** Le plafond de 50 000 est la
digue posée devant — ce qui explique qu'il ne se relève pas : le relever ferait échouer les requêtes
au lieu de refuser les dépôts.

**Trois autres constats du même creusement :**

- **Aucun binaire n'est conservé, nulle part.** Détruit après extraction pour `ConversationFile`
  (`unlink` explicite) ; pour `ProjectFile`, le fichier temporaire n'est **jamais déplacé** — lu,
  puis perdu. « Stocké dans le Drive » ne décrit l'état d'aucun fichier du produit.
- **L'extraction est plus pauvre que du Markdown, et elle en frôle la syntaxe.** Word/ODT : les
  tableaux sortent déjà en `| cellule | cellule |`, les listes en `• texte`, **et le niveau des
  titres est jeté** (`Title` retourne son texte nu). PDF : `getText()`, texte plat, aucune structure
  à récupérer. C'est ce relevé qui a retourné la question du format — passer au Markdown ne perd
  rien, il récupère.
- **La fiche de projet ne compte pas ses propres fichiers de référence** : `ProjectHome` affiche
  conversations, recherches et documents. Ils n'existent que dans la modale d'édition.

## ✅ L'dicO — le moteur tourne, et il a changé de nature (2026-08-06)

**On pose une question, on reçoit une synthèse citée, des sources, des relances. On clique une
source : sa page s'ouvre à côté, lisible, PDF compris. On l'envoie au chat.** L0 et L1 complets,
L2 partiel, **L4 ouvert et déjà bien entamé**.

⚠️ **La structure a changé le 2026-08-05 : le fil de tours est supprimé** (décision #0006). Une
recherche = **une requête**, l'historique est plat, reformuler crée une recherche neuve. Tout ce
qui, plus bas, parle de « tours », de « relance » ou de `SavedSearchTurn` décrit **l'état
antérieur** — c'est conservé pour la trace, pas pour l'état courant.

**63 commits sur `featLdicoMoteur`, 602 tests back + 55 front. Rien n'est poussé.**

## 🔧 Chantier de fond — L'dicO (cadré le 2026-08-04)

Objectif et décisions : `docs/specs/search/moteur.md` · Surface : `docs/specs/search/surface-navigation.md`
Contenant d'affichage : `docs/specs/core/screen.md` · Infrastructure : `docs/specs/search/ldico.md`
Ordre des travaux : `docs/ROADMAP.md`.

**Le cadrage du 2026-08-04 a changé la nature du chantier.** On ne répare plus l'existant : on
**reconstruit en parallèle**, derrière un *contrat de récupération* qui sépare le rendu des
sources et du stockage. Tout ce qui est mesuré ci-dessous décrit donc l'**ancien build** — c'est
un constat qui informe les décisions, pas une liste de correctifs à appliquer.

Le découpage A1/A2/A3 est **abandonné** : il mélangeait un état de corpus, une surface et une page,
et il avait déjà provoqué deux pertes de renvois.

### Le corpus se remplit, la recherche ne le lit pas — mesuré

**`featCrawler` est fusionné dans `develop` et le crawl tourne en prod** (dernier passage le
2026-08-04 à 00:33, `CrawlScheduleProvider`). Le déploiement est donc fait. Mais la vérification
décisive du jalon — que `findEnrichedResults` prenne le dessus — **échoue**. Mesuré sur la base
`search` :

| | Sources | Pages crawlées | Contenus enrichis |
|---|---|---|---|
| origine `farming` (semis Prospect) | 294 | 205 | **167** |
| origine `search` (vraies recherches) | 185 | 51 | **1** |

Et `search_content_match` est à **0 sur 562 recherches** depuis janvier 2026.

**La chaîne a quatre maillons, un seul fonctionne :**

1. **Détecter quoi crawler** — ✅ `CrawlPriorityService` lit les domaines les plus vus dans
   `search_result` et en crée des `crawl_source` ; 185 créées, 177 visitées cette nuit. Ce maillon
   **est branché et tourne** — contrairement à ce que la roadmap laisse croire.
2. **Ramener des pages** — ⚠️ rendement quasi nul : 51 pages pour 177 sources (0,29 par source,
   contre 1,7 côté farming), dont 13 refus en 403. **Cause non vérifiée** : reste à lire
   `CrawlService` pour savoir si `lastCrawledAt` est posé même quand rien n'est ramené.
3. **Enrichir** — ❌ jamais déclenché côté search. `CrawlSource::$needsEnrichment` vaut `false`
   par défaut ([CrawlSource.php:52]) ; `SeedLdicoHandler` (Prospect) le passe explicitement à
   `true`, `CrawlPriorityService` (search) ne le fait pas. En base : `farming` 294/294 à `true`,
   `search` **1/185**. Or la requête d'enrichissement filtre sur `s.needsEnrichment = true`
   (`CrawledContentRepository.php:63`). **Une ligne manquante, plus un rattrapage sur 184 sources.**
4. **Retrouver l'enrichi au moment d'une recherche** — ❌ jamais rendu. `findByThemeAndKeywords`
   est un ET de trois conditions : `qualityScore >= 0.5` (innocenté, 120 contenus sur 168 sont
   au-dessus de 0.8), `theme` en **égalité stricte**, et un `LIKE '%mot%'` sur le JSON des
   mots-clés. Les deux vocabulaires ne se rencontrent jamais : le corpus stocke des syntagmes
   (« logiciels de gestion », « garantie bancaire »), la requête en produit d'autres (« dalles
   podotactiles », « 60x40 »). S'ajoute que 28 % des requêtes sont classées `autre`, ce qui fait
   sauter le thème et ne laisse que le `LIKE`. **C'est le vrai travail.**

**En une ligne** : L'dicO sait déjà quoi crawler, mais il ne l'enrichit pas, et ne saurait de toute
façon pas retrouver ce qu'il a enrichi.

**Ce que le cadrage en a fait.** Ces défauts ne sont plus traités comme des correctifs mais comme
la démonstration que le corpus **ne doit pas être le point de départ**. Deux conséquences actées :
le corpus se construira **par l'usage** (chaque page lue au tier 2 est résumée et stockée, donc sur
des pages réellement remontées pour de vraies requêtes), et il devient un **fournisseur
opportuniste** derrière le contrat, jamais un passage obligé. Le crawler nocturne garde son rôle de
fond d'index.

Le drapeau `needsEnrichment` à `false` côté `search` reste néanmoins **une ligne à corriger**, avec
un rattrapage sur 184 sources : c'est le correctif le moins cher du projet.

### Ce qui n'est PAS mélangé — vérifié le 2026-08-04

La question s'est posée de savoir si le crawler de L'dicO et celui de Prospect étaient confondus.
**Ils ne le sont pas**, et il ne faut pas re-poser la question :

- **Trois bases, aucune partagée** : MySQL (produit), PostgreSQL `search` (L'dicO), PostgreSQL
  `prospect` (leads). Connexions, EntityManagers, entités et migrations séparés.
- **Deux crawlers distincts** : celui de L'dicO (`Command/Crawl*` + `CrawlService`, plusieurs pages,
  écrit dans l'EM `search`) et celui de Prospect (`CrawlSiteHandler`, **une seule** descente sur la
  home pour en extraire des signaux techniques, écrit dans l'EM `prospect` **uniquement**). Le
  second n'écrit jamais dans `crawled_content`.
- **Un seul point de contact, à sens unique** : `SeedLdicoHandler` sème un domaine découvert par la
  mine dans `crawl_source` (origine `farming`). **Le retour n'existe pas** : aucun code n'écrit le
  signal `'content'` côté Prospect — `CrawlSiteHandler` et `ScoreMatchHandler` se contentent de le
  *préserver* s'il existait. Le flywheel Vision v2 est donc, en pratique, un tuyau à sens unique de
  Prospect vers L'dicO.

**Arbitrage du user (2026-08-04) : les semis farming restent dans l'index de L'dicO.** Un site
crawlé pour la mine est une source d'information légitime pour le moteur — la mine doit s'exploiter
de plusieurs manières. Le déséquilibre 167/1 n'est donc pas à corriger en retirant le farming, mais
en réparant le maillon 3.

### Ce que renvoie réellement Staan — corrigé le 2026-08-05

> ⚠️ **La mesure du 2026-08-04 était juste mais incomplète, et elle a fait prendre trois décisions
> sur une image fausse.** Elle décrivait l'**appel nu** — celui que le code fait. Les autres champs
> existent : ils sont en **opt-in**. Ce qui suit remplace la version précédente.

Réponse racine : `search_id`, `query` (avec `altered_query`), `web.results[]`, `confidence`.
L'appel nu rend bien six champs par résultat — `title`, `url`, `snippet`, `display_url`,
`hostname`, `favicon_url`. Les paramètres suivants en ajoutent :

| Paramètre | Effet mesuré sur « guerre Iran détroit Ormuz situation actuelle » |
|---|---|
| `full_content: markdown\|html` | contenu extrait, **7 à 9 résultats sur 10** |
| `extra_snippets: true` | passages **reclassés et scorés** par un reranker, 3 par URL (`max_snippets`), seuil `min_score` |
| `published_date` | **8 sur 10** — n'arrive jamais sur l'appel nu, seulement avec l'un des deux ci-dessus |
| `exclude_domains` / `include_domains` | 10 entrées max, **POST seulement**, mutuellement exclusifs |
| `count` | **doit valoir 10** — confirmé, l'API refuse 6 comme 20 |
| `thumbnail` | au schéma, **jamais rempli** : 0/10 sur six essais |

**Le point décisif : l'API n'oppose AUCUN refus de droits.** Demander `extra_snippets` avec la clé
à 1 € rend un **HTTP 201 et zéro snippet**. Un appel mal facturé passe donc en silence — aucune
exception, aucun journal, aucun test d'intégration ne peut le voir. C'est ce qui rendait l'offre
illisible depuis le code, et c'est la raison pour laquelle le choix de la clé est centralisé dans
`StaanClient` et testé en unitaire.

**Trois offres, et le rôle des deux clés enfin établi** (mesuré en testant chaque clé sur chaque
capacité) :

| Offre | Clé | Ce qu'elle ouvre | Tarif |
|---|---|---|---|
| Web Search | `STAAN_API_KEY` | liste, `display_url`, favicon, `exclude_domains` | 1 €/1000 |
| Web Search for AI | `STAAN_API_KEY_EXTRA` | `extra_snippets` scorés, `full_content`, `published_date` | 2 €/1000 |
| AI Answer (`/answer`) | **aucune** — 403 des deux côtés | réponse rédigée + citations + `related_queries` | contrat commercial |

- **`STAAN_API_KEY_EXTRA` *est* la clé du tier 2**, et elle était déjà en place. Le « en attente
  d'un tiers » de la roadmap n'a jamais été un blocage. Le nom disait vrai : *extra* = extra snippets.
- **`full_content` passe aussi sur la clé à 1 €.** C'est un **trou de facturation chez eux**, pas
  une permission — l'offre le vend dans le paquet à 2 €. Traité comme option AI dans le code : s'y
  appuyer, ce serait perdre une fonctionnalité un matin sans avoir rien changé.
- **AI Answer est fermé**, ce qui clôt une question de positionnement sans avoir à l'arbitrer :
  la synthèse reste chez Mistral.

**Le reranker règle le problème de qualité des sources mieux qu'une liste noire.** Sur la même
requête, appel nu : **premier résultat = un post Facebook**. Avec `extra_snippets`, le classement
devient `legrandcontinent.eu` (0,885), `lemonde.fr` (0,857), `franceinfo.fr` (0,845),
`defense.gouv.fr` (0,784), `bbc.com` (0,763) — et les deux vidéos YouTube tombent en 8ᵉ et 9ᵉ avec
**aucun score**, faute de contenu extractible. Elles coulent d'elles-mêmes. Le filtre de domaines
reste utile en garde-fou, mais ce n'est plus lui qui fait le travail.

**Le score de pertinence est le premier signal objectif du produit.** Le score utilisé jusqu'ici
était dérivé du rang — une paraphrase du classement. Celui-ci mesure l'adéquation du contenu à la
question. C'est de quoi alimenter la porte de suffisance aval (D1bis), qui n'avait aucun instrument.

⚠️ **Le tier 2 est bien plus léger que craint, mais la mesure est fragile** : 22 251 caractères pour
9 pages (~5 500 tokens), là où la spec annonçait un dépassement de la fenêtre de Small. La requête
mesurée tombait sur beaucoup de vidéo et de social, donc des pages courtes. **À re-mesurer sur une
requête à longs articles avant d'en faire une règle.** Et `extra_snippets` coûte de toute façon
moins que `full_content` : 3 passages ciblés contre une page entière.

### Faits mesurés qui contraignent la reconstruction

- **Le front n'a aucun routage interne.** Une seule route protégée, `/`, qui rend `Dashboard` —
  lequel n'est pas une page mais un `if` à trois branches sur le store Zustand. Aucune conversation
  n'a d'URL. Un moteur dont les résultats n'ont pas d'URL n'est pas un moteur.
- **`Conversation` porte huit satellites** (`Message`, `ConversationFile`,
  `ConversationInstruction`, `ConversationSummary`, `Tag`, `ModeTrialUsage`, `Project`, plus
  `ThoughtTree` via les messages). C'est la raison pour laquelle une recherche reçoit ses **entités
  propres** au lieu d'être greffée dessus.
- **`Message.content` est déjà en `encrypted_text`** : le snapshot d'une recherche héritera du
  chiffrement AES-256-GCM sans mécanique nouvelle.
- **Le taux de répétition des requêtes est de 6,5 %** (565 recherches, 528 jeux de mots-clés
  distincts) — et les deux plus gros paquets, 14 et 12 occurrences, sont des tests. Hors eux, ~2 %.
  **Un cache mutualisé entre utilisateurs n'a rien à capturer à cette échelle.**
- **TipTap est déjà installé et utilisé**, en mode headless, comme passerelle Markdown → DOCX
  (`markdownToDocx.ts`). Extensions choisies, pont Markdown fonctionnel, export Word opérationnel.
  Il ne manque que la liaison React pour un éditeur visible.
- **Aucune API de recherche** : `SearchService::search()` n'a qu'un seul appelant dans tout `src/`,
  `ChatController.php:204`, à l'intérieur d'un tour de chat.
- **`SearchClassifier` est un second classifieur**, survivant du lot 3, qui coûte un appel Small
  **par recherche**, avant toute synthèse. À ne pas emporter dans la reconstruction.
- **Le design et l'application partagent déjà l'essentiel** : accent `#2563eb` identique à
  `--color-primary`, police Poppins identique. Mais la palette du handoff est plus fine que le jeu
  de jetons (11 gris de texte contre 4, 7 traits contre 2) et **donnée en valeurs dures** : sans
  extension du jeu de jetons, les nouveaux écrans resteront clairs en thème sombre.

### Vocabulaire fixé le 2026-08-04

Deux malentendus ont coûté une demi-journée. Les mots sont désormais arrêtés :

- **« Recherche enregistrée »** = l'objet persisté (requête, snapshot, sources), réouvrable. C'est
  déjà le mot du design. **Ne pas dire « artefact ».**
- **« Panneau de sortie »** = la fenêtre latérale qui affiche une production. C'est ce que « artefact »
  désignait dans la tête du user.
- **« Farming »** : pas de collision. Le module Prospect a vocation à devenir l'outil Farming de la
  colonne — même objet, autre moment. Non connecté pour l'instant.
- **« Un seul outil actif à la fois »** remplace « un seul écran actif » : le panneau n'est pas un
  outil, c'est une vue sur une production.

## ✅ Livré — refonte du flux de réponse (en ligne le 2026-08-04)

**Fusionnée dans `develop` et déployée sur dev.lintello.ai.** Lots 0 à 7, 362 tests.
Spec : `docs/specs/core/flux-reponse.md` · Décisions : #0001, #0002, #0003.

Quatre migrations MySQL appliquées : `conversation_summary`, `conversation_instruction`,
`message.created_at` en `DATETIME(6)`, `mode.slug`.

**Ce que le déploiement a appris — à relire avant le prochain.** Le lot 0 a sorti les vrais
secrets de `.env` (suivi par git, désormais rempli de sentinelles `change_me_in_env_local`)
vers `.env.local`, qui est **gitignoré donc jamais déployé**. Conséquence vécue :
`JWT_PASSPHRASE` et `APP_SECRET` manquaient sur le serveur, l'authentification rendait un 500
et **plus personne ne pouvait se connecter**. Le `.env.local` de chaque cible doit être
renseigné à la main, une fois, avant la première mise en ligne. Trois points à retenir :

- `ENCRYPTION_KEY` ne se régénère pas : elle déchiffre des colonnes déjà en base
  (`user_context.phone_number`, `siret_number`, `conversation_summary.contenu`). Ne jamais la
  remplacer ni la copier d'un environnement à l'autre.
- La passphrase JWT du serveur avait été perdue → paire de clés régénérée. Sans conséquence
  hors déconnexion générale, mais à ne pas découvrir en pleine mise en ligne.
- `APP_DEBUG` valait `1` en environnement `prod` : Symfony servait les traces d'exception
  complètes aux visiteurs de la bêta. Corrigé.

**Panne silencieuse repérée au passage** : la table `error_log` n'a rien enregistré depuis
avril 2026, alors que `ApiExceptionListener` tourne bien (le front reçoit sa réponse
formatée). Un journal d'erreurs muet, c'est une panne qui en cache d'autres. À traiter.

**Livré et validé en conditions réelles (2026-07-31)**

- **Lot 0** — `.env` assaini remis sous git, garde-fou sur la clé de chiffrement, config PHPUnit
  migrée en schéma 10. Sans ça la suite de tests ne démarrait plus.
- **Lot 2** — `PromptAssembler` : hiérarchie déclarée, résolution par dimension. Le décalage
  d'un tour est mort — les consignes portaient sur le message *précédent*. Ton et valeurs du
  socle remis en place (décision #0003).
- **Lot 3** — classifieur unique. Un seul appel Small remplace cinq classifieurs qui se
  contredisaient. Mode et modèle décidés sur le **sens**, requête de recherche **dérivée du
  sens**, mode déduit **jamais persisté** (donc plus de verrouillage), badge porté par le
  message et non par la conversation.
- **Lot 4bis** — déclencheur de recherche passé au classifieur, livré avec le branchement.
- **Lot 4** (2026-08-03) — **consignes durables**. Détection dans l'appel existant (dimension,
  texte reformulé, portée), stockage par dimension avec remplacement et non empilement,
  bandeau d'affichage et annulation en un clic, veto de réinstallation. Deux corrections de
  conception au passage : la dimension TON était déclarée au niveau 1 (donc « tutoie-moi » ne
  pouvait structurellement pas fonctionner — le vouvoiement est passé en défaut de niveau 6),
  et la verbosité était encore pilotée par des regex de surface malgré le lot 3
  (`VerbosityResolver` la déduit désormais du sens). 240 tests.

- **Lot 5a** (2026-08-03) — **sélection du contexte sous budget**. Budget en **tokens** et non
  en messages, allocateur générique où les sources se disputent l'enveloppe par priorité
  déclarée, documents anciens injectés en résumé. **Huit pansements retirés**, dont
  `findLastMessagesWithFiles` — qui réinjectait le texte intégral de toutes les pièces jointes
  à chaque tour, indéfiniment et hors de tout budget : c'était le vrai poste de coût. Aussi
  supprimées : l'heuristique de verbosité par regex (repli désormais neutre), le seuil
  `messageCount <= 2` de la clarification, la limite magique à 6 messages avec images, le
  logger d'enquête sur les répétitions. 276 tests.

- **Lot 5a bis** (2026-08-03) — **le budget se calcule au lieu de se déclarer** :
  `fenêtre(modèle) − réserve de réponse − marge − incompressibles`, puis plafonné par le plan.
  Corrige trois défauts du 5a : le budget ignorait le **modèle** (un budget calibré pour Large
  envoyé à Small fait échouer la requête), le prompt système et le message courant partaient
  **hors enveloppe**, et rien n'était réservé pour la réponse. Fenêtres en configuration, basses
  par défaut : les modèles sont en `-latest`, leur fenêtre peut changer sans déploiement.

**Arbitrage produit posé par le user (2026-08-03) : la qualité du message passe avant
l'économie de tokens.** Origine : avoir perdu des données en butant sur le mur de contexte,
sans pouvoir résumer. Conséquences pour le 5b — ne compacter **qu'au dernier moment**, faire des
résumés **généreux** plutôt qu'économes, et conserver le mur `max_messages_per_conversation` +
résumé manuel, qui est le filet de sécurité voulu. À rapprocher de la réétude des plans : les
défauts actuels (4 000 à 32 000) brident sous ce que la fenêtre autorise.

- **Lot 5b** (2026-08-03) — **compaction**. Ce qui sort du budget est résumé et conservé au lieu
  d'être jeté : entité `ConversationSummary` (portée, date, origine, purge), une par tranche,
  idempotente par empreinte. Le résumé passe **avant l'historique brut et la recherche** dans
  l'enveloppe — vingt tours pour quelques centaines de tokens — mais **après le tour précédent**,
  qu'aucun résumé ne remplace. Les deux derniers pansements tombent : `contexte.ancrage`
  **supprimé**, et `socle.anti_repetition` **descendu du niveau 1 au niveau 6**. 309 tests.

- **Lot 6a** (2026-08-03) — **découpage de `MistralService`**, préalable au branchement de Flow.
  Quatre commits à comportement constant : `MistralClient` (le transport nu, la même requête
  était recopiée à sept endroits), `TurnRecorder` (l'écriture du tour), `TurnPayloadBuilder` +
  `TurnPayload` (ce qu'on envoie, décomposable). `MistralService` passe de 12 dépendances à 5
  et perd ~700 lignes. **Le `lazy` du compacteur est retiré** — c'était le critère de réussite :
  le cycle n'est pas contourné, il n'existe plus. `getLastRawUsage()` (état mutable relu après
  coup) meurt avec `chatRaw` ; le coût voyage désormais avec la réponse. 334 tests.

- **Lot 6b** (2026-08-03) — **Flow reçoit le même payload**. `FlowContext` porte ce que chaque
  étape reçoit avant sa consigne : le socle en `role: system` (il était **concaténé dans un
  message `user`**), et la conversation (Flow ne la voyait pas du tout). Deux étapes ne
  recevaient aucun prompt système : `doCritique`, et `processExpertPrompts` qui **remplaçait**
  la constitution par le pré-prompt expert — il s'y ajoute désormais. Le payload se construit
  dans `ThinkingEngine`, sur le modèle des **étapes** et non de la synthèse : un contexte taillé
  pour Large envoyé à Small fait échouer la requête. **`logUsage` est branché sur Flow** : le
  chemin à 5-7 appels était le seul absent de `usage_log`. 345 tests.

- **Lot 7** (2026-08-03) — **la recherche entre dans la hiérarchie**. Les deux critères d'origine
  étaient déjà tenus par les lots 3 et 4bis (décision prise sur la conversation, requête dérivée
  du sens) ; le vrai contenu était ailleurs. Le bloc de résultats ne portait pas que de la
  donnée : il embarquait des consignes de **forme** (comment citer, pas de section de liens),
  dans un message `user`, donc hors hiérarchie — et **dupliquées en deux versions
  contradictoires** selon le chemin (`formatWebResultsForContext` interdisait les URL nues,
  `formatClientResults` les demandait). Donnée et consigne séparées : bloc déclaré
  `forme.citation`, dimension nouvelle **CITATION**, niveau 6. Une consigne durable du fil
  l'évince désormais — impossible tant qu'elle vivait dans la donnée. La règle n'est émise que
  si les résultats **survivent au budget** : sinon le prompt est réassemblé sans elle, citer des
  sources absentes revenant à en inventer. Et **Flow cesse de jeter la recherche** : elle était
  exécutée, payée, stockée puis ignorée — l'interface annonçait « je recherche… » pour une
  réponse sans source, et aucun tour Flow ne nourrissait le flywheel L'dicO faute d'URL citée.
  355 tests.

**Reste à faire**
- ~~Lot 8 — modes en ENUM~~ : **écarté**, `mode.slug` suffit.
- **Filtre de domaines** (ex-7b) : la cascade ramène parfois un post Facebook ou un Scribd.
  Pansement assumé, adossé à l'avancement du crawler — plus L'dicO est nourri, plus
  `findEnrichedResults` prend le dessus. À poser seulement si la gêne persiste.

**Non éprouvé en conditions réelles : les lots 5b, 6b et 7.** Une seule séance les couvre tous
les trois — une vraie conversation, longue, avec une question qui déclenche une recherche, en
Flow. À contrôler :
1. `conversation_summary` se remplit (5b) ;
2. une relance (« reprends le point 3 ») a enfin un référent, et `usage_log` se remplit sur les
   tours Flow (6b) ;
3. les sources sont citées **en lien inline**, sans section « Sources » en fin (7).

**Le point de tension à surveiller : la taille par appel en Flow.** Chaque étape emporte
maintenant le socle, l'historique ET les résultats de recherche, plus un contexte cumulé qui
grossit d'étape en étape. Le budget est calculé pour **un** appel, pas pour sept, et le contexte
cumulé n'est borné par rien. Sur Small (fenêtre déclarée à 32 000), une conversation longue avec
recherche est le cas qui peut franchir le mur. Ce n'est pas une dégradation : c'est un échec de
requête.

**Outil de diagnostic** : `bin/console app:turn:explain "un message"` donne l'intention, le
domaine, la décision de recherche, la requête réellement envoyée, le modèle, la verbosité, la
consigne de forme détectée avec sa portée, et les modes candidats.

**Campagne de non-régression** : `bin/phpunit --group live` rejoue 28 cas de référence contre
le vrai Mistral Small (`tests/Fixtures/classification-reference.json`). Hors suite par défaut.
À lancer avant ET après toute modification du prompt du classifieur — c'est le seul moyen de
voir une dilution, qui ne casse aucun test unitaire. **Nécessite `MISTRAL_API_KEY` dans
`.env.test.local`** (gitignoré) : `.env.local` n'est pas chargé en environnement de test, et
`.env` ne porte que la sentinelle.

## ✅ Livré — socle de L'dicO, premier temps (2026-08-05)

Branche `featLdicoMoteur`. **383 tests** (362 → 383), `lint:container` vert, vérifié sur l'API
réelle avec `bin/console app:search:explain`.

- **Le contrat de récupération** (`src/Service/Search/Contract/`) — la surface ne parle ni aux
  sources ni au stockage. `Block`, `Tier`, `SourceFilter`, `RetrievalRequest`, `WebSource`,
  `Retrieval`, deux interfaces, `RetrievalService`. Deux garde-fous **codés, pas commentés** :
  un enrichisseur ne peut ni **ajouter** ni **reclasser** une source (premier critère de « fait »
  du moteur), et une panne d'enrichissement dégrade sans faire échouer.
- **Bascule entre fournisseurs sur exception OU résultat vide** — même règle d'or que
  `SearchCascade`. C'est ce qui permet à l'étage payant de **décliner** quand le rendu n'a besoin
  de rien qu'il facture, sans que le contrat connaisse la tarification de personne.
- **`StaanClient`** (`src/Service/Search/Staan/`) — le transport isolé, sur le modèle de
  `MistralClient`. **C'est lui qui choisit la clé**, déduite de ce que les options demandent :
  l'API ne renvoyant aucune erreur de droits, la règle ne pouvait pas vivre chez l'appelant.
  Une sentinelle `change_me_in_env_local` est traitée comme une clé absente — sinon un
  environnement mal renseigné servirait des résultats non enrichis en silence.
- **`display_url` et `favicon_url` récupérés** — le design les réclamait, l'API les rendait
  gratuitement, le mapping les jetait. 10/10 sur l'API réelle.
- **Filtre de domaines** (ex-lot 7b) — `search.excluded_domains`, 7 entrées sur 10 possibles.
  **Zéro ligne de code** : c'est un paramètre de l'API, et il est dans l'offre à 1 €.
- **`app:search:explain`** — le pendant de `app:turn:explain` pour la recherche. Rendu
  indispensable par le silence de l'API : c'est le seul moyen de voir ce qu'on reçoit et à quel
  tarif.
- **`api_usage` distingue `staan` de `staan_ai`** (0,001 € / 0,002 €), donc C1 aura le coût réel
  par étage au lieu d'un agrégat.
- **Handoff de design versé** dans `docs/design/navigation-multi-outils/` — il n'existait que dans
  un zip sur un poste. Bien plus précis que ce que la spec en résumait : valeurs au pixel par
  composant, `SCREEN_COPY`, comportements.

- **La recherche enregistrée** (2026-08-05) — `SavedSearch` + `SavedSearchTurn`, en **MySQL**,
  migration `Version20260805120000` écrite à la main. `project_id` nullable (tout est utilisable
  hors projet), snapshot en `encrypted_text`, rang des tours **explicite** et non déduit de la
  date — `Message` s'ordonnait par `created_at`, ce qui avait coûté un bug réel le 03/08.

  ⚠️ **Elles devaient s'appeler `SearchThread` / `SearchTurn`. Impossible, et le piège mérite
  d'être retenu** : Doctrine résout les mappings par **préfixe de chaîne**, donc
  `App\Entity\SearchThread` est revendiquée par l'EntityManager PostgreSQL du VPS (préfixe
  `App\Entity\Search`), dont le répertoire ne la contient pas. Elles étaient **mappées nulle
  part** — `mapping:info` les ignorait, `schema:update` proposait de supprimer les tables qu'on
  venait de créer, et **rien n'a levé la moindre erreur**. Repéré parce que `schema:validate` a
  été lancé après la migration, pas avant. Règle inscrite dans `docs/conventions.md`, gardée par
  `tests/Entity/NommageDesEntitesTest.php` — dont l'échec a été vérifié sur un fichier piège.

- **L'API de recherche** (2026-08-05) — `SearchController` sous `/api/searches` : lister
  (paginé, filtrable par projet, `?project=none` pour le hors-projet), lancer, relancer, rouvrir,
  rattacher/renommer, supprimer. `SavedSearchService` orchestre, `SearchSnapshot` fige.

  **La recherche existait comme ingrédient, pas comme fonctionnalité** : `SearchService::search()`
  n'avait qu'un appelant dans tout `src/`, à l'intérieur d'un tour de chat. C'est ce que ce
  contrôleur change.

  Deux principes s'y voient : l'appartenance est **dans la requête SQL** (`findOneOwnedBy`), pas
  dans un contrôle qui suit — une recherche a une URL partageable, donc son identifiant circulera ;
  et **rouvrir ne coûte rien**, la distinction étant portée par le verbe HTTP (seuls les `POST`
  engagent un appel externe).

  Vérifié contre la vraie base MySQL : titre **et** snapshot illisibles en clair en base, rangs
  1-2 conservés, accès par un autre compte **refusé**, rejeu en **13 µs**, cascade propre à la
  suppression. La synthèse reste à `null` — c'est L1.

- **Le routage** (2026-08-05) — **L0 est complet.** `/chat/:conversationId` et
  `/projet/:projectId` existent ; une conversation et un projet ont désormais une URL, et
  rechargement, retour arrière et lien collé retrouvent le même écran.

  **Le sens est unique : URL → store**, dans `useRouteSync`, seul point de synchronisation du
  front. La tentation inverse (« je change d'écran, donc je pousse l'URL ») marche au clic et
  casse partout ailleurs — un rechargement arrive avec une URL et un store vide. Les six
  composants concernés **naviguent** désormais au lieu de poser l'écran courant : Sidebar,
  ProjectHome, ChatWindow, WelcomeHome, ProjectCreateModal, ProjectEditModal,
  TransferConversationModal. Effet de bord appréciable : l'alignement « le projet suit la
  conversation », qui était recopié dans chaque appelant, vit maintenant à un seul endroit.

  **Le piège de l'asynchronisme est traité** : sur un `/chat/:id` rechargé, la liste des
  conversations est vide le temps de l'appel — « introuvable » et « pas encore chargée » sont
  alors le même état observable, et rediriger trop tôt renverrait à l'accueil une URL valide.
  D'où `conversationsLoaded` ; compter les éléments ne suffit pas, un compte neuf en a zéro.
  Le défaut aurait été **intermittent**, donc invisible en développement.

  **Repli SPA vérifié des deux côtés** (`.htaccess` et nginx) : un rechargement sur une URL
  profonde sert bien `build/index.html`. Rien à changer côté serveur.

  22 tests front (6 nouveaux sur le routage), build et lint verts. ⚠️ **`public/build` est
  gitignoré : rebuild obligatoire au déploiement**, sinon l'ancien bundle tourne — sans routes.

  `/recherche` n'est **pas** créée : elle arrivera avec son écran en L1. Une route sans écran
  ne serait qu'un trou, et le mécanisme est prêt à l'accueillir.

**Reste du socle (L0)** : ~~rien~~ — ✅ **complet**.

## 🔧 L1 en cours — la surface

- **Jeu de jetons** (2026-08-05) — palette du handoff, clair **et** sombre. Voir la dette de
  contraste plus bas : arbitrage « fidélité stricte », conséquence connue à D7.
- **La synthèse** (2026-08-05) — Small, systématique, une fois par tour neuf, écrite sur les
  **passages scorés** de l'offre AI. Rend le texte à renvois numérotés, 2-3 chiffres clés
  rattachés à leur source, 3 relances **de natures différentes**, et une **suffisance déclarée**.

  Deux garde-fous sont **codés, pas promis** — ils tiennent les critères de « fait » 1 et 4 du
  moteur : un renvoi vers une source inexistante est **retiré du texte** (et jamais renuméroté,
  ce qui fabriquerait une citation fausse), et un chiffre sans source vérifiable est **écarté**.

  **Mesuré en conditions réelles** sur la requête de référence : **5 051 tokens d'entrée +
  485 de sortie, 3,8 s** — à quoi s'ajoutent les 1,7 s de récupération, soit **~5,5 s** pour une
  recherche neuve. `usage_log` l'enregistre, échec compris. C1 a son instrument.

  **Un défaut trouvé par cette séance et corrigé** : les trois chiffres clés venaient de la
  source 4 pendant que le texte ne citait que 1 et 3 — le badge « Citée n » aurait manqué la
  source la plus exploitée de la page. Porter un chiffre compte désormais comme avoir servi.

  ⚠️ **À surveiller sur l'écran** : les relances produites font 120 à 160 caractères, alors que
  le design les dessine en **chips de 30 px**. Soit le prompt les raccourcit, soit l'écran les
  accueille autrement. À trancher en construisant la carte de synthèse.

- **La colonne à cinq outils** (2026-08-05) — deux vues exclusives (`Outils` / `Projets`),
  action primaire **contextuelle** (son libellé suit l'outil), « Rechercher partout » pour le
  `Ctrl K`, compteurs **dérivés** des données, projet déplié **groupé par outil**, 258 px.
  Styles dans `ToolColumn.css`, préfixe `tc-`, **entièrement en jetons** : `index.css` (8 000
  lignes) n'est pas touché, aucune régression sur les écrans non repris.

  **L'outil actif est dérivé de l'URL**, comme le reste : le prototype le gardait en état local,
  ce qui aurait fait retomber un rechargement sur l'outil par défaut. Cinq routes ajoutées —
  `/recherche` (accueil fonctionnel : composer, filtres, recherches enregistrées) et
  `/farming`, `/documents`, `/drive` en **états d'attente honnêtes**, sans bouton d'appel à
  l'action : un bouton qui ne fait rien est un mensonge d'interface.

  **Deux capacités récupérées au passage** — le nettoyage du code mort les a révélées :
  - le renommage, le déplacement et la suppression d'une conversation **disparaissaient** avec
    l'ancienne colonne. Le handoff ne les dessine pas, mais son prototype est une démo de
    navigation sur données factices, pas un inventaire des capacités. Remis au survol ;
  - `ProjectEditModal` — instructions du projet, mode par défaut, export, suppression —
    n'était atteignable **qu'immédiatement après la création d'un projet** : la modale fermée,
    plus aucun chemin n'y menait. Défaut **préexistant**, réparé (bouton de réglages au survol).

  Accessibilité : anneau de focus visible sur tout ce qui se clique, actions atteignables au
  clavier (`:focus-within`). Le handoff ne la spécifiait pas — c'était à cadrer, pas à improviser.

- **L'écran de restitution** (2026-08-05) — `/recherche/:id`. Champ de requête **persistant et
  collant** (reformuler ne fait pas quitter l'écran), filtres, carte Synthèse IA avec **renvois
  numérotés cliquables** qui font défiler jusqu'à la source et la mettent en évidence, relances,
  colonne droite (emplacement visuel + chiffres clés cliquables), liste de sources familière avec
  favicon, badge « Citée n », date et deux ponts vers les autres outils. Mobile : visuel en pleine
  largeur au-dessus du texte, sources compactes.

  **Les renvois sont des renvois, pas des liens** — l'inverse du chat. Sur une page de résultats
  la source est déjà sous les yeux ; l'envoyer dehors casserait la lecture. Les **sources** partent
  dehors, les **productions** restent dedans.

  **Deux boutons sont affichés mais désactivés**, avec l'explication au survol : « Exporter en
  document » (le panneau de sortie est en B2) et « Ajouter au Drive » (outil non branché). Les
  masquer aurait caché la trajectoire du produit ; les laisser actifs aurait menti.
  « Discuter dans le chat » **fonctionne** : la source part vers une conversation neuve avec sa
  référence, en réutilisant `pendingMessage`.

  **Forme JSON vérifiée** contre les types du front avec `app:search:explain --snapshot`, qui
  affiche exactement ce que reçoit l'interface. Testée sur le relevé de prix qui avait échoué en
  Flow (23 302 tokens pour un tableau inexploitable) : rend cette fois des prix réels rattachés à
  leurs sources, trois chiffres clés et trois relances de natures distinctes.

- **La capacité de mise en page** (2026-08-05) — **L1 est complet.** `.app-layout` étant en flex
  et `.main-content` en `flex: 1`, une région latérale à largeur fixe suffit à faire rétrécir la
  zone principale. `outputPanelStore` porte le contenu, `MainLayout` rend l'`<aside>` **seulement
  s'il y en a un** : rien ne l'ouvre aujourd'hui, aucune coquille vide n'est montrée. Mobile =
  plein écran (la feuille montante supposerait un geste que rien d'autre n'emploie).

  **Un bug réel trouvé en posant ce gabarit** : `.main-content` est en `overflow: hidden` et
  **chaque écran doit fournir son propre conteneur de défilement** — c'est ce que fait
  `ProjectHome`. Mes trois écrans neufs ne l'avaient pas : **la liste de sources était coupée au
  bas de la fenêtre, sans barre de défilement.** Corrigé avec le motif du dépôt
  (`flex: 1; min-height: 0; overflow-y: auto` sur la racine, contenu borné par un `-inner`
  centré) — ce qui est aussi ce qui permet au panneau de rétrécir la zone sans déplacer la barre
  de défilement.

  Finition au passage : la puce de citation gardait une marge à droite, d'où un point qui se
  détachait de la phrase (« techniques ⟨1⟩ . »).

**L1 est livré.** ⚠️ **Deux choses seulement ont été vues en navigateur** — l'écran de restitution
et la colonne, sur captures. Le reste (vue Projets dépliée, thème sombre, mobile, états vides)
compile et passe le lint, mais l'œil n'y est pas passé.

**Arbitrage du user (2026-08-05) : contenu en pleine largeur, non centré.** La maquette était
dessinée sur un viewport de ~924 px, où 940 px centrés remplissaient l'écran ; sur un écran large,
la moitié de la place restait vide. Seule exception conservée : une **mesure de lecture de 88 ch**
sur les paragraphes de synthèse — au-delà, l'œil perd sa ligne au retour.

## 🔧 L2 en cours — enrichissement

- **La mine d'`og:image`** (2026-08-05) — le dernier bloc qu'aucune offre ne rendait. Mesuré :
  **4 sources sur 7** portent une image, rendue par `og-image`, et le visuel remonte jusqu'au
  snapshot.

  **La conception évidente était mauvaise.** Aller chercher les dix pages nous-mêmes, c'est
  exactement ce que fait le crawler nocturne — 0,29 page par source, 13 refus en 403, parce qu'il
  se présente seul face aux protections anti-bot. Or `full_content: html` rend le **HTML brut**,
  `<head>` compris (vérifié : 4 `og:image` sur 10, en 439 ms), dans l'appel qu'on fait déjà.
  **L'infrastructure de Staan encaisse les refus, pas la nôtre.**

  Coût : **638 Ko contre 7,5 Ko** en Markdown. Ce volume ne quitte jamais le serveur — le HTML
  atterrit dans `WebSource::$rawHtml`, **jamais** dans `richText`, précisément pour qu'il ne
  puisse pas devenir matière de synthèse par accident. Ni le snapshot ni le prompt ne le voient.

  C'est le **premier vrai enrichisseur** : il ne choisit rien, ne peut rien ajouter, et ne fait
  aucun appel réseau. Le tier 2, lui, avait dû devenir un fournisseur.

  ⚠️ **Décision 8 à reprendre, et elle devient concrète** : on stocke l'URL, donc le navigateur de
  l'utilisateur appellera le serveur de **chaque site listé** et lui livrera son adresse IP. Les
  favicons ne posaient pas ce problème (une seule infrastructure, Qwant). Pour un produit dont la
  souveraineté est l'argument, c'est à trancher avant l'ouverture publique.

  Un seuil inventé au passage puis retiré : un premier jet écartait les URL « trop courtes » au
  prétexte du pixel de suivi. **Aucune preuve, et il rejetait `photo.jpg`.** Un test fige
  désormais son absence.

- **Le corpus rempli par l'usage** (2026-08-05) — décision 3 de `moteur.md`. Les pages remontées
  pour de **vraies requêtes** sont versées au corpus PostgreSQL. Renversement complet par rapport
  au crawl nocturne, qui remplissait l'index avec autre chose que ce que les gens cherchent —
  0 correspondance sur 562 recherches.

  **Il n'enrichit pas et ne va rien chercher.** Résumer chaque page coûterait dix appels modèle
  par recherche, sur le chemin critique, pour un bénéfice qui ne sert qu'aux recherches futures :
  on écrit la matière et on pose `needsEnrichment`, le `ContentEnricher` nocturne fait le reste
  quand personne n'attend. Et le HTML est celui déjà rapporté par l'offre AI — aucune requête
  sortante, aucun 403 à encaisser.

  **Mesuré en conditions réelles** : 6 pages versées, corpus `search` passé de **187 à 193
  sources et de 1 à 7 à enrichir**. Idempotent — le second passage écrit 0. Latence ramenée de
  **724 ms à ~330 ms** en regroupant les lectures (deux requêtes au lieu de vingt) ; c'est le
  prix d'un PostgreSQL distant joint depuis un mutualisé.

  ⚠️ **Ces ~330 ms sont sur le chemin critique** d'une recherche déjà à ~5,5 s, pour un bénéfice
  différé. Le bon geste serait un envoi asynchrone drainé par cron (`infra_topology` : pas de
  worker persistant). À faire si la latence devient le sujet.

- **`needsEnrichment` corrigé** (2026-08-05) — **la ligne signalée depuis le 04/08**, « le
  correctif le moins cher du projet ». `CrawlPriorityService` créait ses sources sans le drapeau,
  là où `SeedLdicoHandler` le posait : d'où 294/294 côté farming contre **1 sur 185** côté search.
  ✅ **Rattrapage fait le 2026-08-07** — **193 lignes** et non ~186 (le code corrigé avait entre-temps
  créé 111 sources avec le drapeau, d'où un état différent de la note du 05/08). Mesure faite avant
  de lancer : ces 193 sources ne débloquent que **37 pages** réellement enrichissables — l'écart dit
  la même chose que le 04/08, ces sources n'ont quasiment jamais ramené de page (0,29 par source).
  Aucun pic de coût, et `CrawlEnrichCommand` est plafonné à 50 par passage. Liste des ids et
  `UPDATE` inverse conservés hors dépôt le temps de vérifier.

- **Un défaut trouvé par un test, et il est de la famille qui compte** : `&nbsp;` décode en
  **espace insécable** (U+00A0), que `\s` ignore sans le modificateur `u`. Le français en est
  truffé — avant `:`, `€`, `?`. Le corpus stockait donc du texte parsemé d'espaces invisibles, et
  un `LIKE '%bande podotactile%'` n'y trouvait rien. **C'est la même famille de défaut que celle
  qui rend 0 correspondance sur 562 recherches.** Corrigé pour les deux chemins d'un coup :
  l'extracteur est sorti de `CrawlService` dans `HtmlText`, partagé par le crawler nocturne et
  l'usage — s'ils extrayaient différemment, l'enrichisseur verrait deux qualités selon l'origine.

**Reste en L2** : infobox SearXNG et dataviz — **tous deux volontairement écartés pour l'instant**.
L'infobox serait invisible (`Block::INFOBOX` existe, l'écran du handoff ne le dessine nulle part) ;
la dataviz forcerait le choix de bibliothèque graphique que `ROADMAP.md` gèle exprès, sur le plus
petit de ses quatre besoins. Le **crawler nourri par Staan** est **retiré de L2** : le crawler de
fond passe en suspens (2026-08-08), voir le carnet ci-dessous.

## ✅ L3 — le quota anti-abus est armé (2026-08-08)

**Le reste de L3 est reporté avec le crawler** (arbitrage du user, 2026-08-08) : le moat reposait
sur le corpus, le corpus reposait sur le crawler, et le crawler est en stand-by. Le raisonnement
complet vit dans le carnet ci-dessous — il n'a pas à être rouvert avant que l'usage réel ait parlé.

`POST /api/searches` tombait dans `api_global` : **200 req/min**, une garde anti-DDoS, pas un quota.
Chaque recherche engage pourtant un appel Staan AI (2 €/1000) et une synthèse Mistral Small.

Deux seaux, parce que les deux abus ne se ressemblent pas — la boucle client s'emballe en secondes,
le jeton volé se consomme sur des heures : **10/min** (`token_bucket`) et **200/jour**
(`sliding_window`). L'ordre est signifiant et le raccourci aussi : buter sur la minute ne consomme
**pas** de jeton journalier, sinon un client qui réessaie se verrouillerait 24 h à cause d'une
limite censée se régénérer en une.

- **Seul le `POST` est compté.** Rouvrir rejoue un snapshot déjà payé.
- **Plafond unique pour tous les plans, délibérément** : les quotas par plan relèvent de C1, sur
  relevés réels. En fixer un ici referait l'erreur d'arbitrer sur un coût de revient faux. Ordre de
  grandeur du garde-fou : 200 recherches ≈ **0,55 €** par compte et par jour, au lieu d'un montant
  non borné.
- **Vérifié** : le `default_lifetime: 300` du pool `cache.rate_limiter` **n'écrase pas** la fenêtre
  d'un jour — `CacheStorage` pose son propre `expiresAfter()` depuis
  `SlidingWindow::getExpirationTime()` (~2 jours). Sans cette vérification, le plafond se serait
  réinitialisé toutes les 5 minutes, en silence.
- **Défaut préexistant corrigé au passage** : le front résout `error` **avant** `message`
  (`api.ts`, `getErrorMessage`) et le 429 envoyait `error: 'Too Many Requests'`. Tous les
  utilisateurs limités voyaient cette chaîne anglaise — **sur le chat depuis toujours**.
- 14 tests sur la table de routage des seaux, qui n'en avait aucun.

⚠️ **Aucun 429 n'a été exercé en vrai HTTP.** Les seaux existent dans le conteneur, la table est
testée, mais le refus lui-même n'a pas été déclenché depuis un navigateur.

## ⏸ Farming & crawler — le carnet de débug (ouvert le 2026-08-08)

**Arbitrage du user (2026-08-08) : le crawler de fond passe en suspens, le farming reste branché,
et ni l'un ni l'autre ne passe avant le MVP de septembre.** Motif énoncé : trop de temps déjà perdu
sur un farming qui ne fonctionne pas encore, alors que la livraison visée est LintellO (chatbot +
L'dicO). Ce carnet existe pour que **rien ne soit à redécouvrir** le jour de la reprise.
⚠️ **Rien de ce qui suit n'est à faire maintenant.**

### Ce que la séance a établi — trois outils dans deux tables

Le vrai défaut n'est pas le rendement, c'est le **mélange** :

| | Métier | Objectif | Ce qui le mesure |
|---|---|---|---|
| **Crawler de fond** (`CrawlService`) | **anticiper** | répondre sans appel externe ; actif monétisable | % de recherches servies sans sortir |
| **Farming** (branche `origin = farming`) | **qualifier** | signaux techniques de la home d'une TPE | entreprises scorées |
| **`CorpusFeeder`** | **mémoriser** | relire une page, servir le viewer | pages relisibles |

Trois objectifs, trois métriques, **aucune commune** — et ils partagent `CrawlSource`,
`CrawlService`, le passage nocturne, `runLimit`, le User-Agent et le rythme.

**Ce que le mélange a déjà coûté**, et ça se date :
- la ligne `needsEnrichment` — un champ, deux écrivains, aucun propriétaire : 294/294 côté farming
  contre **1/185** côté fond, rattrapé le 07/08 ;
- l'asymétrie **invisible** — les chiffres agrégés avaient l'air corrects, il a fallu séparer par
  origine le 04/08 pour voir 167 contre 1 ;
- `runLimit: 100` partagé : chaque outil consomme le quota de l'autre, sans que rien ne le dise.

**Pourquoi le fond rate et pas le farming — l'explication n'avait jamais été écrite.** Ce n'est pas
le code, c'est la **population visée** :

| origine | ce qu'il descend | pages/source |
|---|---|---|
| `farming` | la **home** d'une TPE locale + 4 pages internes | **1,7** |
| `search` | des **URL profondes** de gros sites vus en résultats | **0,29** |

Une home de PME locale n'a ni Cloudflare, ni paywall, ni mur anti-bot. Une URL profonde de
`lemonde.fr` ou `service-public.fr`, si. **Séparer les deux outils ne fera donc pas revenir les
pages du fond** — ça rend seulement la question posable, avec un propriétaire et une métrique.

**Et `CorpusFeeder` ne remplace pas le crawler de fond** — erreur commise puis corrigée dans la
séance. Il ne stocke une page qu'**après** qu'on l'a payée au fournisseur : il ne fera jamais
économiser un appel. Avec un taux de répétition des requêtes de **6,5 %** (~2 % hors tests), un
corpus qui n'attrape que le déjà-cherché ne resservira quasiment jamais. Mémoire ≠ anticipation.

### À faire le jour de la reprise, dans cet ordre

**1. Sortir le farming dans `FarmingCrawler`** *(commencé le 08/08 puis annulé — rien n'est resté
dans le dépôt, l'arbre est propre)*
- Extraire d'abord le **geste** partagé dans un `PageFetcher` : requête espacée (1 s), extraction
  `HtmlMarkdown`, écriture créer-ou-mettre-à-jour, fraîcheur `+7 days`.
  ⚠️ **Il doit rester partagé** : le 07/08, l'extraction vivait en deux exemplaires et les `&nbsp;`
  n'étaient décodés que d'un côté — l'enrichisseur voyait **deux qualités de corpus** selon qui avait
  visité la page en premier. Deux stratégies peuvent diverger, deux extractions non.
- `FarmingCrawler` ne garde que **sa stratégie d'URL** : home + jusqu'à 4 pages
  « à-propos / services / … » et `absolutizeSameHost`, aujourd'hui un `if` sur l'origine planté au
  milieu de `CrawlService::getUrlsToCrawl()` ([CrawlService.php:218](src/Service/Search/CrawlService.php:218)).
- Sa commande propre (`app:crawl:farming`) et son `CrawlRun::kind = 'farming'`, pour que les chiffres
  soient enfin lisibles séparément.
- ⚠️ [CrawlCheckCommand.php:93](src/Command/CrawlCheckCommand.php:93) filtre sur `kind === 'crawl'`
  pour l'alerte « 0 page écrite ». Sans mise à jour, **le farming perd son alerte** au moment même
  où il gagne son kind.

**2. Débrancher le fond** — sortir `analyze` et `run` de
[CrawlScheduleProvider](src/Scheduler/CrawlScheduleProvider.php:20), garder le code avec sa raison
écrite dedans. `app:crawl:run` reste appelable à la main : c'est ce qui permet le rattrapage de
l'historique sans remettre l'outil au planificateur.

**3. ⚠️ `enrich` doit SURVIVRE au débranchement — c'est le piège de l'opération.** Le passage
nocturne enchaîne `analyze → run → enrich`, mais `ContentEnricher` **n'appartient pas au crawler** :
depuis le 05/08 il est l'étage d'enrichissement de `CorpusFeeder`
([CorpusFeeder.php:26](src/Service/Search/CorpusFeeder.php:26)). Couper le cron nocturne en bloc
arrête l'enrichissement de **tout** le corpus, y compris les pages qui arrivent gratuitement des
vraies recherches — **en silence**, et ça ne se verrait que des semaines plus tard devant un corpus
de pages brutes. `enrich` doit devenir sa propre tâche planifiée.

**4. `CorpusFeeder` prend son origine `usage`.** Il écrit aujourd'hui `ORIGIN_SEARCH`
([CorpusFeeder.php:215](src/Service/Search/CorpusFeeder.php:215)) — la **même valeur** que le
crawler suspendu, donc « suspendre l'origine `search` » ne désigne rien de précis. C'est le défaut
`needsEnrichment` qui recommence. Pas de migration (colonne `varchar(20)`, valeurs libres).
⚠️ **L'historique n'est pas relabellisable** : les lignes `search` existantes mélangent les deux
écrivains et **rien ne permet de les distinguer** (`needsEnrichment` est à `true` partout depuis le
rattrapage du 07/08, `lastCrawledAt` est nul des deux côtés). Ne pas deviner — seules les lignes
neuves prennent `usage`, et on assume que l'avant restera illisible.

**5. `findDueForCrawl()` ne filtre pas par origine**
([CrawlSourceRepository.php:36](src/Repository/Search/CrawlSourceRepository.php:36)) : il rend
**toutes** les sources actives, y compris celles de `CorpusFeeder`. Donc aujourd'hui le fetcher
nocturne repasse sur des domaines déjà couverts — il dépense des requêtes et encaisse des 403 pour
des pages qu'on a déjà. À borner par origine en même temps que le reste.

### Ce que le report emporte avec lui — le moat de L3

**Arbitrage du user (2026-08-08) : « je ne dois pas prévoir une bombe pour tuer des fourmis ».** Le
crawler est en stand-by parce qu'il est trop lourd et trop complexe pour l'usage actuel — et les
deux postes restants de L3 tombent avec lui, pour la même raison. **On lance le produit en test, on
regarde l'usage, on décide après.** Ce qui suit n'est pas une question ouverte : c'est la trace du
raisonnement, pour ne pas le refaire.

- **Corpus comme fournisseur opportuniste** — sa raison d'être était de servir une recherche *sans*
  payer le web. Sans crawler, le corpus ne contient que du **déjà-cherché** (`CorpusFeeder`), et le
  taux de répétition des requêtes est mesuré à **6,5 %** (565 recherches, 528 jeux de mots-clés
  distincts), ~2 % hors tests. Le brancher marcherait ; ça servirait deux recherches sur cent.
  ⚠️ **La mesure a une limite qu'il faut dire** : elle porte sur un usage quasi mono-utilisateur et
  largement composé de tests. Avec de vrais utilisateurs sur des sujets qui se recoupent, ce serait
  autre chose — et c'est **inmesurable avant d'avoir ces utilisateurs**. C'est précisément ce que le
  lancement en test doit produire.
- **Et même ces 2 % ne marcheraient pas en l'état** : `findByThemeAndKeywords` croise un `theme` en
  **égalité stricte** (28 % des requêtes sortent en `autre`, ce qui fait sauter la condition) avec un
  `LIKE '%mot%'` sur le JSON des mots-clés — deux vocabulaires qui ne se rencontrent jamais. Et ce
  chemin n'est lu que par `SearchService` (le **chat**) : `RetrievalService` (le moteur) n'a **aucun**
  fournisseur corpus.
- **Cache mutualisé** — même cause, en pire : déjà noté comme n'ayant rien à capturer à cette échelle.

**En une phrase** : le corpus ne peut pas être un moat tant qu'il **se souvient** au lieu
d'**anticiper**. Rouvrir le sujet suppose de répondre d'abord à « que veut-on posséder ? », et cette
réponse viendra de l'usage, pas d'une analyse de plus.

**Trois réponses possibles, notées pour le jour où la question se repose** — elles ne sont pas
équivalentes et le choix est un choix de produit, pas de code :
1. **Un index qui suit l'usage**, qui s'épaissit là où les gens vont. Peu cher, s'autofinance, mais
   toujours en retard d'un pas : c'est un cache, pas un moat.
2. **Un index qu'on décide** — on choisit des champs et on les possède en profondeur. Cher, demande
   un choix éditorial, mais c'est le seul qui se revend.
3. **Le moat n'est pas le corpus.** Ce que L'dicO a déjà et que ses concurrents n'ont pas, c'est la
   **souveraineté** : page servie depuis notre stockage, images passées par notre proxy, aucun appel
   tiers depuis le navigateur du lecteur. C'est construit, c'est livré, et ça ne coûte plus rien.

### Ce qui restait déjà ouvert côté Prospect (inchangé, rappelé ici pour ne pas le rechercher)

- 2 migrations SQL à appliquer sur le VPS (`crawl_source.origin`, `target_profile.delivery_hour`).
- Cron **harvest** à trancher (livraison réelle vs avocat) ; réconcilier les scripts cron
  serveur ↔ dépôt.
- Règle `content` dans `signalRules` — **et le retour du flywheel n'existe pas** : aucun code n'écrit
  le signal `'content'` côté Prospect, `CrawlSiteHandler` et `ScoreMatchHandler` se contentent de le
  *préserver*. Le tuyau est à sens unique, de Prospect vers L'dicO.
- Casiers de livraison **sans authentification** — bloquant pour vendre.
- Avocat.

## À trancher / bugs connus

### Ouvert par le viewer de page (2026-08-06)

- **Le viewer affiche le TEXTE d'un PDF, pas le PDF.** Ni mise en page, ni colonnes, ni figures.
  Et **2 PDF sur 6** restent illisibles (scannés ou protégés à la copie). La piste qui donnerait
  un vrai « oui » : afficher le PDF **nativement** (`<embed type="application/pdf">`) pour le
  lire — mise en page et figures comprises, scannés inclus — en gardant l'extraction en arrière-plan
  pour le chat et le corpus. Coût : le navigateur appellerait le serveur du document (décision 8),
  et certains serveurs refusent l'embarquement. **Arbitrage du user (2026-08-06) : on en reste là**,
  le viewer sera perfectionné avec les Documents, le Drive et TipTap.
- **L'OCR des PDF scannés — ⏸ rattaché à B2 (arbitrage du user, 2026-08-07)**, avec le
  perfectionnement du viewer. ~~Vérifié le 07/08 : `OCR_REMOTE_URL` est vide dans `.env` comme
  dans `.env.local`.~~ ⚠️ **Constat périmé, et sa conclusion était fausse** — voir la correction
  du user du 2026-08-18 : l'OCR d'image fonctionne en prod, et une sonde SSH y ment. Deux choses
  à ne pas reperdre avant d'y revenir. D'abord une
  **inconnue à lever avant tout chiffrage** : `hertzg/tesseract-server` v3 accepte-t-il un PDF en
  entrée ? Tesseract seul ne le fait pas — si la réponse est non, ce n'est plus « exposer un
  conteneur » mais **écrire un service devant** (pdf → png par ImageMagick, puis Tesseract), et
  l'ordre de grandeur change. Ensuite une **conséquence plus large que le viewer** : `OcrService`
  préexiste au moteur et sert le module fichiers ; avec l'URL vide il retombe sur `exec('tesseract')`,
  un binaire qui n'est pas installable sur mutualisé. **L'OCR des images envoyées dans le chat est
  donc probablement inopérant en prod depuis toujours, en silence — à vérifier.** Si c'est le cas,
  exposer l'endpoint n'est pas une amélioration du viewer mais la réparation d'une fonctionnalité
  entière, et ça remonte indépendamment de B2. Détail ci-dessous.

- **L'OCR des PDF scannés reste ouvert, mais pas pour la raison écrite ici.** ⚠️ **Ne pas
  confondre deux sujets** (mis au clair le 2026-08-18) : l'OCR d'une **image** fonctionne sur le
  mutualisé — le user l'a corrigé, voir plus haut, et la vérification par la console donne un faux
  négatif sur cette infra. Ce qui reste réellement bloqué, c'est le **PDF**, et pour une raison de
  code : `OcrService::extractTextFromImage()` prend une image, il faut donc convertir les pages
  avant. Ce qui suit décrit ce vrai sujet-là.
  ~~`OCR_REMOTE_URL` est **vide** — le conteneur tourne sur le VPS mais n'est pas exposé en
  HTTPS.~~ Et `OcrService::extractTextFromImage()`
  prend une **image** : il faudrait convertir les pages en images avant (ImageMagick ou Ghostscript,
  absents du conteneur PHP et **non installables sur mutualisé**). Bonne conception : que le service
  OCR du VPS accepte directement un PDF et fasse la conversion lui-même, il a déjà ImageMagick.
  Côté application, ~20 lignes dans `PdfReader`.
- **Le bruit de navigation se voit maintenant.** « Aller au contenu principal », menus, pieds de
  page se retrouvent en tête des pages lues : `HtmlMarkdown` ne retire que les blocs *balisés*
  `nav`/`header`/`footer`/`aside`, et beaucoup de sites emploient des `<div class="nav">`. C'est la
  question d'un vrai extracteur de contenu principal (densité de texte, type Readability) — un
  chantier, pas un réglage.
- **Le curseur d'historique « 1 / 6 » du handoff n'est pas implémenté**, et il fait probablement
  double emploi : chaque recherche ayant son URL, le retour du navigateur ramène déjà à la
  précédente. À trancher, peut-être à abandonner.
- **Les trois boutons désactivés du viewer attendent le MÊME jalon — B2** : « Document »,
  « Ajouter au Drive » et **« Rattacher »**. Ce dernier a rejoint les deux autres le 2026-08-07 :
  la question juridique est bien tranchée (voir ci-dessous), mais **rattacher une page suppose un
  endroit où la ranger**, et c'est le Drive et l'objet Document qui le fourniront. Le coder d'ici
  là — entité, migration, routes, section « Pages » — serait à refaire une fois le Drive livré.

- ✅ **La « question juridique unique » était deux questions — séparées et tranchées le 2026-08-07.**
  Les avoir liées avait un coût observable : le bouton « Rattacher » du viewer restait désactivé
  derrière un arbitrage qui portait, lui, sur **Prospect — parké depuis le 30/07**.

  1. **Enregistrer une page dans un projet — tranché : le markdown dérivé**, purgé avec le projet.
     Argument du user : *« je ne vole pas un document, pas plus que si avec Firefox je mets un site
     en favoris »*. Les trois critères qui font que ça tient sont réunis, et ce sont les bons —
     **c'est l'utilisateur qui déclenche**, **c'est pour lui**, **ça ne ressort pas**. Ce n'est donc
     pas une seconde finalité mais la finalité d'origine. Et si c'est un copier-coller, alors c'est
     bien le markdown qu'on garde : un favori qui casse quand le site tombe n'est pas ce qui est
     décrit.

     ⏸ **Mais l'implémentation est REPORTÉE à B2** (arbitrage du user, 2026-08-07) : le bouton
     « Rattacher » dépend du **Drive et des Documents**, qui fourniront l'endroit où ranger la
     page. Le juridique ne le bloquait donc pas seul — le produit non plus n'est pas prêt. Coder
     maintenant une entité `ProjectPage` avec sa migration et sa section reviendrait à la refaire
     une fois le Drive livré.
  2. **Fiche entreprise du farming depuis une recherche — ⏸ dort avec Prospect.** Là, oui : verser
     ce que l'utilisateur cherche *pour lui* dans une base de prospection est une **seconde
     finalité**, décidée sans lui ni la personne concernée. Elle se réveillera avec le module, et
     c'est à ce moment-là qu'un avocat aura quelque chose de concret à lire.

  ⚠️ **Le seul endroit où l'argument du favori ne s'étend pas, et il est déjà en production** : le
  **corpus rempli par l'usage** (livré le 05/08) verse les pages lues dans le PostgreSQL partagé,
  **sans action de l'utilisateur**, et pour servir les recherches **des autres**. Les trois critères
  y tombent tous les trois. C'est le fonctionnement normal d'un index de moteur de recherche et ça se
  défend très bien — mais **autrement**, et ce n'est pas ce qui a été tranché ici. À regarder à part.

  Le texte qui suit décrit l'état antérieur des deux questions ; conservé pour la trace.

- 🔵 ~~**UNE SEULE question juridique, posée deux fois — à trancher ensemble.**~~ *(dépassé, voir
  ci-dessus)*
  1. **Enregistrer une page dans un projet** : persister une *référence* (URL + métadonnées) ou
     un *markdown dérivé* ? (bouton « Rattacher » du viewer, désactivé)
  2. **Créer une fiche entreprise du farming depuis une recherche** (intuition du user,
     2026-08-06) : « on rentabilise la recherche ».

  **Le RGPD ne juge pas la donnée, il juge la finalité.** L'utilisateur cherche un commerce
  *pour lui* ; verser ce qu'il trouve dans une base de prospection est une **seconde finalité**,
  décidée sans lui ni la personne recherchée — dont beaucoup sont des commerçants en nom propre,
  donc des personnes physiques. La conformité art. 28 de Prospect tient à une chaîne précise
  (mine → récolte → dédup permanent → restitution par lien, sans rétention) qu'un versement
  latéral court-circuiterait. Et **Prospect est parké** depuis le 30/07.

  ✅ **Le code est prêt pour le jour où c'est tranché** : `PracticalFactsExtractor` est un
  service **autonome et sans dépendance** — il prend du HTML, il rend une fiche. C'est ce que la
  note Q13 du 05/08 demandait de vérifier « avant de le coder deux fois ». Alimenter Prospect
  serait un branchement, pas une réécriture.

- ~~**Vue Projets : bugs signalés par le user**~~ — **traités le 2026-08-06** : le chevron d'un
  projet menait à la racine de l'outil au lieu du projet, et la page projet n'affichait que les
  conversations. Le « doublon au rattachement » était **une fausse piste** (14 recherches, 14
  identifiants distincts ; deux `PATCH` en 200 dans les logs) — très probablement le second
  défaut, vu depuis deux écrans qui ne racontaient pas la même chose. Confirmé résolu par le user.

- ~~🔴 **La chaîne de migrations ne reproduit pas le schéma réel**~~ — **réparé le 2026-08-07**
  (`Version20260807160000`). La comparaison porte désormais sur **372 colonnes et tous les index des
  37 tables**, pas seulement sur `user` : la réserve du 05/08 est levée, il n'y avait rien d'autre.

  **Cinq écarts, dont un fatal.** `user.email_hash` + son index unique n'étaient créés par aucune
  migration → `Unknown column 't0.email_hash'`, donc authentification à terre sur une installation
  fraîche. Origine datée : commit `39edc4e` (« add ENCRYPTION system ») a ajouté la propriété à
  l'entité sans écrire la migration ; dev a suivi par un `schema:update`, la chaîne non. Les quatre
  autres : `letter_postal_code` en `varchar(20)` alors que la colonne porte un `encrypted_string`
  (un chiffré n'y tient pas), `yearly_price_ht` en `NOT NULL` alors que l'entité la déclare nullable,
  une table `file` créée sans qu'aucune entité `File` n'existe, et `messenger_messages` avec trois
  index simples au lieu de l'index composite.

  **Chaque geste est conditionnel** : la prod porte déjà `email_hash`, un `ADD COLUMN` inconditionnel
  y échouerait au déploiement — la même panne dans l'autre sens. Vérifié dans les deux sens : 11
  requêtes sur une base vierge (qui la rend identique à dev), 2 requêtes sans effet sur une copie du
  schéma de dev.

  **Reste, et c'est volontairement hors de cette migration** : Doctrine signale quatre écarts de
  **métadonnées** (commentaires `DC2Type` sur `api_usage.day` et `thought_tree.tree_data`, deux index
  à renommer). Ils sont présents **en dev aussi** — ce n'est pas la chaîne qui dérive, c'est le
  mapping qui a bougé sans conséquence fonctionnelle. Tant qu'ils sont là, `schema:validate` ne sera
  pas totalement vert. À traiter séparément, sans urgence.

  Conséquence de bord inchangée : il n'existe **aucune base de test fonctionnelle** utilisable. Les
  tests sont tous unitaires, ce qui explique qu'aucun n'ait vu les cinq écarts.


- 🔑 **`STAAN_API_KEY` à régénérer.** Elle a été exposée en clair dans un message d'erreur de shell
  le 2026-08-04 pendant le sondage de l'API. Régénérer côté Staan, puis mettre à jour `.env.local`
  en local **et** sur le serveur. ~~Une seconde clé sera à créer pour le tier 2.~~ **Elle existe
  déjà** (`STAAN_API_KEY_EXTRA`) — voir la section Staan ci-dessus.

- **YouTube n'est pas dans la liste d'exclusion, délibérément.** Sur une question factuelle c'est
  du bruit ; sur un « comment faire » c'est une vraie source. Et le reranker le fait couler tout
  seul (aucun score, faute de contenu extractible). À reconsidérer si la gêne persiste — il reste
  3 places sur les 10 qu'autorise l'API.

- ~~**Le tier 2 est branché mais aucun écran ne le demande encore.**~~ **Tranché le 2026-08-05,
  décision #0005** : le moteur L'dicO passe par l'étage payant, chat et farming restent à 1 €, le
  crawler y passera le jour où il migrera. `RetrievalRequest::moteur()` et `::economique()` portent
  la décision dans le code, deux tests la verrouillent. Vérifié sur l'API : **8/8 en matière de
  synthèse, 1,7 s**.

- **Rien de vivant ne consomme encore `RetrievalService`** — son seul appelant est
  `app:search:explain`. Le chat et le farming passent toujours par `SearchService` /
  `SearchCascade`. C'est le travail de L0/L1, et c'est aussi ce qui rend le moteur inoffensif
  pour l'existant tant qu'il n'a pas de surface.

- **Le préambule résiste.** Malgré l'interdiction explicite, les réponses s'ouvrent encore
  parfois par « Voici… ». La dilution prédite en #0003 est confirmée. Méthode retenue :
  **enlever** des règles concurrentes, pas en ajouter. À traiter une fois le prompt stabilisé.
- **Qualité des sources citées** : la cascade ramène parfois un post Facebook ou un Scribd.
  C'est le problème que L'dicO existe pour résoudre — plus le crawler tourne, plus le chemin
  `findEnrichedResults` prend le dessus. Un filtre de domaines est possible en attendant.
  **Le lot 7 a levé un frein invisible** : aucun tour Flow ne remontait d'URL citée, donc le
  flywheel n'apprenait rien de ces tours. Il apprend désormais des deux flux.
- **Coûts, plans et quotas** : à réétudier **après le branchement de L'dicO, avant livraison**.
  `ModelDetectionConfig` était le levier qualité/coût et il est **hors service** depuis le lot 3
  — à rebrancher, surtout pas à supprimer.

- **Piste posée le 2026-08-03 : borner Flow par plan.** Question du user — « n'activer Flow que
  pour les comptes pro qui ont du budget ? ».

  **Ce qui existe déjà**, et qu'il faut avoir en tête avant de bouger quoi que ce soit : Flow
  est **déjà fermé au plan Gratuit**, côté API et pas seulement dans l'interface
  (`ChatController`, garde explicite contre l'appel direct). Il est ouvert à partir de
  `student`. Et un second garde-fou, plus fin qu'un verrou, existe : la synthèse tourne sur
  Large **si le quota Large le permet**, sinon elle retombe sur Small. Un `student` à court de
  quota garde donc Flow, en version tout-Small — dégradation, pas refus.

  **Pourquoi ne rien trancher maintenant** : `usage_log` ne contient **aucun tour Flow**, le
  `logUsage` de ce chemin n'ayant été branché qu'au lot 6b. Et le coût vient de changer — les
  lots 6b et 7 font emporter à chaque étape le socle, l'historique et les résultats de
  recherche. L'ordre de grandeur « Flow ≈ 5 à 7 fois un tour normal » est périmé. Décider un
  seuil commercial aujourd'hui, c'est refaire l'erreur qu'on vient de corriger : arbitrer sur
  un coût de revient faux.

  **Séquence** : la séance d'usage réel remplit `usage_log` → quelques jours de dogfood
  donnent une distribution → on tranche avec des nombres.

  **Recommandation à ce stade** : un **quota de tours Flow par mois et par plan**, plutôt qu'un
  verrou binaire sur `pro`. Trois raisons — ça respecte l'arbitrage « réponses avant quotas »
  (quand Flow tourne, il tourne à pleine qualité, on borne le nombre et pas la réponse) ; un
  verrou sur `pro` retirerait le différenciateur le plus visible du produit à `student` et
  `perso`, qui sont les plans de volume du lancement chatbot ; un quota se règle sans
  redéploiement, là où un palier de plan est une promesse commerciale qu'on ne reprend pas.
  C'est aussi l'endroit naturel où **rebrancher `ModelDetectionConfig`**.

  **Précision technique utile au moment de mesurer** : le payload de chaque étape **est** borné
  — `BudgetResolver` prend le minimum entre la fenêtre technique et `max_history_tokens` du plan
  (4 000 en `free`, 8 000 en `student`/`perso`, 16 000 en `pro`, 32 000 en `lintello`). Ce qui
  n'est borné par rien, c'est le **contexte cumulé** que les étapes s'ajoutent entre elles.

- **Piste posée le 2026-08-04 : la porte de suffisance d'information.** Formulée par le user
  après un tour Flow sur un relevé de prix ManoMano : *« si j'ai assez d'info je lance le
  système, Flow ou pas ; si pas assez je pose des questions, et quand je juge que j'en ai assez
  je lance »*. Le modèle avait lui-même relevé l'ambiguïté (taille, couleur, adhésif ou non)
  **et répondu quand même**.

  **Trois constats mesurés, à ne pas reperdre :**
  1. Le mécanisme **existe déjà** : bloc `forme.clarification`, niveau 6, qui demande de poser
     1 à 2 questions avant de répondre si la demande est ambiguë.
  2. **Sa condition d'activation est du code mort.** Le paramètre `historiqueDisponible` de
     `PromptAssembler::assemble()` n'est passé par PERSONNE (vérifié sur tout `src/` et
     `tests/`). Il vaut donc toujours `false`, et le bloc part à **chaque** tour. La consigne
     était bien présente sur le tour ManoMano : elle a été ignorée.
  3. **En Flow, l'architecture l'interdit.** La première étape de la chaîne est « décompose la
     demande en 2 à 3 étapes ». Aucune branche ne permet de conclure « c'est ambigu, je
     m'arrête et je demande ». La question ne peut pas naître d'un système dont la première
     instruction est de décomposer.

  **⚠️ Ne PAS « réparer » le paramètre mort tel qu'il a été conçu** : la clarification
  disparaîtrait dès qu'il y a de l'historique — l'inverse du besoin. Le garde-fou est faux en
  principe, pas seulement débranché. Le cas ManoMano avait de l'historique ET une ambiguïté :
  la bonne question n'est pas « ai-je du contexte ? » mais « ce contexte lève-t-il
  l'ambiguïté ? ». C'est **sémantique**, donc du ressort du classifieur — ce que le commentaire
  du code anticipe déjà.

  **Conséquences de conception, déduites de la formulation du user :**
  - La porte est **avant** le moteur, jamais dedans : sinon on paie cinq à sept appels pour
    aboutir à une question. Mesure du tour concerné : **23 302 tokens d'entrée** pour un
    tableau inexploitable faute de format précisé. Deux questions posées avant auraient coûté
    quelques centaines de tokens. **Ici, la qualité et le coût vont dans le même sens** — c'est
    assez rare pour être noté.
  - Elle se place donc après le classifieur et **avant** l'aiguillage Flow / flux classique.
  - « Quand je juge que j'en ai assez » implique un **état** (clarification en attente) et une
    réévaluation au tour suivant, sinon la boucle ne termine pas.
  - Les réponses de clarification doivent être **capturées** : la décision #0004 en fait le
    déclencheur d'apprentissage des faits, et elles sont aujourd'hui perdues. Traiter la porte
    sans cela obligerait à refaire le travail au lot 9.

  **Même famille, mesurée le 2026-08-04 sur un relevé de prix : le tuyau est à un coup.** La
  recherche est décidée **une fois, avant** le raisonnement (lot 4bis, qui a résolu un vrai
  problème et créé cette limite). Même en Flow, les cinq à sept étapes partagent un jeu de
  résultats **figé** : une étape qui conclut « il n'y a rien ici » ne peut pas aller chercher
  ailleurs. Deux systèmes concurrents testés sur la même demande ont, eux, constaté l'absence
  puis relancé une recherche élargie. Trois moments, une même infirmité — « ai-je de quoi
  commencer », « ce que j'ai trouvé suffit-il », « où chercher sinon ». Détail chiffré et
  conséquences dans `docs/specs/search/moteur.md`, section « Requête de référence ».

  **Pourquoi ce n'est pas un correctif de séance** : ajouter un signal d'ambiguïté au
  classifieur, c'est modifier `IntentClassifier::prompt()` — donc campagne `--group live` avant
  et après, sur le prompt le plus sensible du produit. Et le risque inverse est réel : un
  assistant qui questionne trop est insupportable. La règle actuelle dit déjà « si c'est clair,
  réponds directement » ; le problème n'est pas de l'écrire, c'est de la faire tenir face aux
  pré-prompts de mode. **Rattaché au lot 9.**
- **Schémas** : LintellO produit déjà des diagrammes Mermaid, mais le frontend ne les rend pas
  (`react-markdown` + `remark-gfm` seulement, `CodeBlock` maison sans coloration syntaxique). Ils
  s'affichent en code brut. **Promu au jalon 6 de `docs/ROADMAP.md`** — mais le choix de l'outil
  est repoussé : Flow Sprint 6 et la Suite réclament aussi une mindmap, et on n'installera pas
  trois bibliothèques de rendu graphique.
- **Audit des 17 modes** : certains pré-prompts contredisent la constitution, et le mode Prof a
  fait croire au modèle que l'utilisateur était professeur. Hors périmètre du flux de réponse.

## Dette signalée (à traiter, ne pas oublier)

- **`MistralService` porte trois prompts d'avant la refonte, toujours vivants — inventaire du
  2026-08-19.** Sortie de B10 en **dette séparée** (arbitrage du user) : ce n'est pas le
  mille-feuille de B, c'est ce qui l'a précédé, et le mélanger au lot brouillerait les deux causes.

  Trois prompts système commencent par *« Tu es un assistant… »* pendant que le socle dit *« Tu es
  L'IntellO »* : `generateTitle` (titre de conversation), `summarizeConversation`, et
  `generateFileSummary`. Les trois sont appelés — respectivement par `ConversationController`,
  `ChatController` et `FileService`.

  ⚠️ **`generateFileSummary` est un DOUBLON de fait avec `ResumeDuDocument`** (livré le 18/08) :
  deux résumeurs, deux consignes, deux longueurs (« 3-5 phrases » contre « trois à quatre »), et
  surtout deux intentions — le premier prescrit un **style** (*« concis, informatifs et
  structurés »*), le second un **usage** (*« ce résumé ne sera pas lu par un humain : il servira à
  décider si cette pièce contient la réponse »*). Le second est meilleur et le premier l'ignore.
  Ils portent aujourd'hui sur deux objets distincts (pièce jointe de conversation contre pièce de
  dossier), ce qui rend le doublon supportable — la décision 13 les rapproche pourtant.

  ⚠️ **Et `detectSearchIntent` n'a plus aucun appelant** : vérifié, zéro dans tout `src/`. Du code
  mort qui transporte encore son prompt.

- **Un échec d'upload à la création d'un projet ne se voit pas — constat du 2026-08-15.**
  `ProjectCreateModal` avale chaque échec en `console.error` et poursuit la boucle
  (*« Continuer avec les autres fichiers même si un échoue »*). Au test de charge, le projet a été
  **créé vide** et l'utilisateur amené dessus **sans un mot à l'écran** ; les quatre erreurs
  n'existaient qu'en console. Le choix de continuer est bon ; c'est le **silence** qui ne l'est pas.
  Indépendant de B9 : à corriger quel que soit le sort des fichiers de projet.

- **La troncature d'extraction compte en octets ce que le reste compte en caractères — constat du
  2026-08-15.** `FileService::extractText()` teste `strlen()` et coupe au `substr()`, quand
  `ProjectFile`, `ConversationFile` et les contrôleurs mesurent en `mb_strlen()`. Sur du texte
  français accentué, la coupe peut tomber **au milieu d'un caractère UTF-8**. Latente sur les plans
  qui ne tronquent pas, réelle sur `free` et `pro`. Deux lignes à changer, aucune raison d'attendre.

- **Le stockage n'a pas d'instrument — constat du 2026-08-11.** Intuition du user en sortant du
  recentrage : *« il faudra augmenter les quotas de stockage, on va les exploser »*. Elle est juste
  sur la tendance, et l'analyse déplace le compteur.

  **Ce que le recentrage change, c'est l'incitation, pas le poids.** Jusqu'ici, envoyer un fichier
  servait à un tour de conversation. Maintenant que le projet est un dossier et que l'assistant lit
  le dossier, on donne enfin une raison de déposer. Le volume monte par le **comportement**.

  **Ce qu'on vient de concevoir produit du texte, et le texte ne pèse rien.** Un document TipTap,
  quelques dizaines de Ko. Un Mermaid, quelques Ko. Une page rattachée, du markdown dérivé — la
  mesure disponible est celle de l'appel Staan du 05/08 : **7,5 Ko de Markdown là où le HTML brut
  en pèse 638**, soit un rapport de 85. ⚠️ *Cette mesure porte sur la sortie de Staan, pas sur celle
  de `HtmlMarkdown` côté viewer — même ordre de grandeur attendu, pas vérifié.* Le choix du markdown
  dérivé avait été fait pour des raisons juridiques le 07/08 ; il se trouve qu'il divise le stockage
  par 85.

  **Ce qui pèse, ce sont les fichiers envoyés** — un PDF scanné fait 5 à 20 Mo, plus que tout ce
  qu'un utilisateur écrira. Et ça préexiste au recentrage.

  ⚠️ **Mais le coût n'est pas dans l'octet.** Le stockage OVH ne coûte presque rien. Ce qui coûte
  dans ce qui a été conçu : le **sommaire** (un appel modèle **par entrée**), la **lecture à la
  demande** (des tokens à chaque ouverture), et l'**OCR** le jour où il sera branché. Donc **mille
  notes de 2 Ko coûtent mille fois plus qu'un PDF de 20 Mo**. Le giga-octet est le mauvais
  compteur : il faut compter **l'entrée** autant que l'octet.

  **Ce qui manque et qui est le vrai travail** : *le stockage par compte est-il seulement mesuré
  aujourd'hui ?* **Non vérifié.** À établir avant tout chiffrage.

  **Ne pas fixer de chiffre maintenant** — ce serait refaire l'erreur que C1 vient de corriger :
  arbitrer sur un coût de revient supposé, sans usage. La soupape est déjà posée : depuis le
  2026-08-11, **ce qui dort en corbeille compte dans le quota**.

  Deux effets de bord à ne pas perdre : **le plan Gratuit devient une cible de stockage** — tout
  atterrit désormais dans un projet, neutre compris, et D2 concluait que le risque résiduel après le
  double opt-in était les comptes inertes ; *un compte inerte qui stocke ne l'est plus*. Et
  `specs/suite/suite.md` comptait en **nombre de pages** (50 en Perso, 200 en Pro) — au vu de ce qui
  précède, c'était peut-être le bon compteur pour une mauvaise raison.

- **Contraste de la palette — mesuré, arbitré, assumé (2026-08-05).** Sept des douze niveaux
  d'encre du handoff passent sous le seuil WCAG AA en thème clair, aux tailles où le design les
  emploie : compteurs à **2,01:1**, labels de section à 2,39, placeholder à 2,31.
  **Arbitrage du user : fidélité stricte au handoff** — « le handoff fait foi ». Le motif n'est
  pas cosmétique : corriger aurait **confondu les cinq derniers niveaux en un seul gris**
  (`#6d747e` … `#707783`), effaçant la mise en retrait progressive que le design construit.
  ⚠️ **D7 vise Lighthouse > 90, accessibilité comprise : ce sera un point rouge, et c'est su
  d'avance.** S'y ajoute un défaut que la couleur ne rattrape pas — le design emploie du texte à
  **10 et 10,5 px**. Mesure rejouable : `node scripts/contraste.mjs`, à relancer à toute
  modification de la palette.

- ~~**`MistralService` porte trop.**~~ **Traité au lot 6a** (2026-08-03). Restent chez elle la
  séquence d'un tour et les utilitaires (titre, résumé, classification de requête,
  enrichissement, résumé de fichier) — ces derniers ne dépendent plus que du client et
  partiront quand on y touchera. `buildTextMessageForStorage` et `extractFileMetadata` y sont
  encore : ce sont les pièces jointes vues côté stockage, elles n'ont pas trouvé leur place.
- **`forme.anti_repetition` est en sursis.** Descendue du niveau 1 au niveau 6 au lot 5b, après
  correction de deux causes réelles de redite. **Si les redites ne reviennent pas d'ici quelques
  semaines d'usage, la supprimer** — elle n'a jamais eu d'autre justification que l'absence de
  diagnostic.

- **Deux convertisseurs Markdown → HTML depuis le 2026-08-13.** L'ajout de `league/commonmark`
  (chemin PDF des gabarits, `Service\Document\Export\MarkdownVersHtml`) rend caduc le convertisseur
  écrit à la main de l'export RGPD — `Service\Export\Rgpd\MarkdownToHtmlConverter`, 245 lignes de
  regex ligne à ligne. Les deux ne rendent pas pareil.

  ⚠️ **Ne pas remplacer à l'aveugle** : le RGPD est en production, et surtout son corpus d'entrée
  n'est pas le même — il convertit des **messages de chat**, pas des documents, et sa sortie est
  attendue telle quelle par les gabarits Twig de `templates/export/pdf/`. Vérifier d'abord ce que le
  convertisseur maison fait que `commonmark` ferait autrement (blocs de code, tableaux), couvrir par
  des tests, et comparer un PDF d'export complet avant / après.

- **CRLF/LF** : ~150 fichiers apparaissent « modifiés » selon le git utilisé. Le git de Windows
  (`autocrlf=true`) voit juste, celui de WSL non — **committer depuis Windows**, chemins
  explicites, jamais `git add -A`.
- **`composer install` est désormais obligatoire au déploiement** (depuis le 2026-08-06) :
  `smalot/pdfparser` a été ajouté pour la lecture des PDF. Sans lui, le viewer rendra « page
  indisponible » sur tous les documents — en silence, puisque c'est un cas de figure normal par
  ailleurs. À faire **avec** le rebuild front, jamais l'un sans l'autre.
- **`public/build` est gitignoré** : le front doit être **reconstruit au déploiement**, sinon
  l'ancien bundle tourne (symptôme vécu : badge de mode absent). **Cause profonde trouvée le
  2026-08-03** : les assets étaient nommés sans hash (« pour simplifier ») alors que nginx les
  sert en `immutable, 1 an` — le navigateur ne les retéléchargeait donc **jamais**. Corrigé :
  hash rétabli, `build/index.html` passé en `no-store` côté nginx **et** `.htaccess` (la prod
  OVH n'a pas la conf Docker). Ne jamais retirer le `[hash]` de `vite.config.ts`.
- **Nommage** : le module code s'appelle `Search`, le domaine « L'dicO » / `ldico`.
- **Deux SGBD** : MySQL (principal) + PostgreSQL (Prospect/Search sur VPS). `schema:update
  --force` sur l'EM par défaut voudrait faire `DROP TABLE search_query` — vérifié le 31/07.

## Notes de session

<!-- Ajouter les décisions/observations datées ici, du plus récent au plus ancien. -->

- **2026-08-14** — **B7 livré, B8 livré.** Branche `featGabarits`, **24 commits**,
  **906 tests back · 215 front**, build vert. Non poussé, non déployé.

  **Ce qui marche de bout en bout** : un document choisit sa forme dans le Screen, l'éditeur écrit
  *dans* cette forme, et il sort en **quatre formats** — PDF, DOCX, ODT, Markdown — tous rendus
  côté serveur depuis **un seul jeu de paramètres**. Le walker `commonmark` → PhpWord alimente les
  deux formats bureautiques ; le PDF prend `commonmark` → HTML → gabarit Twig → dompdf. Les
  500 lignes d'export Word du front et la dépendance `docx` ont disparu.

  **La lettre place ses deux adresses**, l'expéditeur venant du **compte** et non plus du corps :
  `UserContext` portait déjà ces champs mais les envoyait **en prose** dans le prompt, à charge
  pour le modèle de les retaper — d'où la colonne unique. Le comportement du chat ne change pas,
  seule la provenance du bloc change.

  ⚠️ **Le constat du user à l'usage, et c'est le sujet suivant** : *« très peu de différence entre
  les deux autres gabarits, en tout cas visuelle »*. Il est juste. **Un gabarit n'est aujourd'hui
  qu'une feuille de style** — police, tailles, marges, couleur d'accent — alors que ce qui sépare
  un compte rendu d'un protocole est **structurel** : titres numérotés, pagination, encadrés,
  mention de version. C'était écrit dans la spec du 12/08 et rien ne l'exerçait. Les règles de
  forme par rôle sont désormais spécifiées (`specs/core/gabarits.md`, décisions 11 à 15).

  **Quatre arbitrages du user pris le 14/08** : la **justification** est un paramètre de gabarit et
  non un bouton (elle ne touche donc pas au stockage) · la **couleur** se pose sur des *rôles*
  (titre, encadré, en-tête de tableau), jamais sur du texte libre · les **images** arrivent par le
  Drive avec un filtre dédié et un redimensionnement automatique · et **modifier le gabarit depuis
  le chat** rejoint B6 — c'est le seul endroit où un modèle est légitime, puisqu'il traduit une
  phrase en paramètres et ne rend rien.

  **Cinq défauts trouvés, dont trois par les tests et deux à l'usage :**
  - **le bloc d'adresses du `.docx` sortait encadré** — `borderSize => 0` fait écrire
    `w:val="single"` (un trait *simple* de largeur nulle) que Word rend en filet fin, et que dompdf
    ignore : d'où un PDF impeccable et un Word cadré ;
  - **l'en-tête de lettre était cliquable en silence**, révélé au survol seulement — le user ne l'a
    découvert que par hasard ;
  - un `useCallback` posé **après** un `return null` anticipé faisait tomber le panneau ;
  - un gabarit incomplet faisait tomber l'éditeur entier ;
  - `ChatMessage` **préchargeait 540 Ko** de TipTap + docx à chaque réponse affichée, pour un
    bouton d'export supprimé depuis le 12/08.

  **La suite de la journée — B7 fini, B8 livré** (17 commits de plus, 24 en tout sur la branche) :

  - **les gabarits se distinguent enfin par la forme, pas par la police.** C'était le constat du
    user, et il était juste : un gabarit n'était qu'une feuille de style. Il porte désormais des
    **règles de structure** — titres numérotés, pagination, corps justifié, encadrés — parce que
    ce qui sépare un compte rendu d'un protocole n'est pas typographique ;
  - **la citation Markdown a un second sens**, et c'est le levier gratuit du lot : `>` existe
    déjà, survit à l'aller-retour, personne ne s'en servait. Elle devient encadré selon le
    gabarit, et **`> [!WARNING]` donne des encadrés typés** — syntaxe GFM, donc rien de maison ;
  - **la pagination**, après la vérification qui manquait. L'ODT la porte ; ⚠️ **le PDF non, pas
    par le CSS** — dompdf rend `counter(pages)` à **zéro** (« Page 1 sur 0 », mesuré), d'où un
    pied écrit après le rendu par son canvas ;
  - **le compte rendu et le protocole ont leur fiche d'en-tête**, et la nature du document se lit
    en tête (« Compte rendu : … ») ;
  - **B8 — les images**, de bout en bout : dépôt redimensionné à 1600 px, ré-encodage par GD
    (qui évacue l'EXIF, donc la position GPS d'une photo de téléphone), incorporation aux trois
    sorties, choix depuis le Drive, et **un schéma s'insère comme une image** ;
  - **le format de sortie a quitté la rangée d'actions** pour un sélecteur nommé, entre le titre
    et le compteur. Le défaut n'était pas la place du bouton mais **l'état invisible** : on ne
    cherche pas un réglage qu'on ignore.

  **Sept défauts de plus, dont quatre invisibles sans la console :**
  - **le `FormData` partait en JSON** — l'instance axios pose `Content-Type: application/json`,
    et sa transformation sérialise alors le formulaire : le fichier disparaissait en route et le
    serveur répondait « Aucun fichier reçu », un message juste qui accuse le mauvais coupable ;
  - **`GET /documents/images` était avalée par `GET /documents/{id}`** : Symfony résout dans
    l'ordre, et `{id}` acceptait n'importe quoi. La route rendait 404 **avec ou sans images** —
    corrigé par une contrainte d'UUID, qui règle la classe entière ;
  - **le dépôt liait l'entité `User` au lieu de son identifiant typé** : sur une colonne
    `BINARY(16)`, Doctrine passe la clé en chaîne et la comparaison ne trouve rien, **sans
    erreur**. L'image existait en base et sur le disque, la route disait « non trouvée » ;
  - **le préfixe `/api` partait en double**, l'instance axios l'ayant déjà pour base ;
  - **le tableau d'adresses sortait encadré dans Word** : `borderSize => 0` fait écrire
    `w:val="single"` — un trait *simple* de largeur nulle — que Word dessine et que dompdf
    ignore ;
  - **les champs d'en-tête d'un gabarit ressortaient sous un autre** (`objet`, `lieu`, `date`
    étaient partagés) : ils sont désormais rangés par casier, **conservés et non effacés** au
    changement de forme ;
  - **l'avertissement TipTap n'était pas décoratif** : le StarterKit 3 embarque `Link`, on en
    ajoutait un second, et laquelle l'emporte n'est pas défini — notre `openOnClick: false`
    pouvait ne jamais s'appliquer.

  📌 **Dix migrations MySQL attendent le déploiement** : les cinq des 10-11 août,
  `document`, `document.template`, `document.template_meta`, `document_image`, et celle du
  2026-08-17 (`resume`, `lecture_seule`, `repris_le`). Et **une dépendance de plus** :
  `league/commonmark` — donc `composer install` obligatoire.

  ⚠️ 📌 **Et un geste qui n'est PAS une migration, à ne pas oublier au même moment** — après
  `doctrine:migrations:migrate`, lancer **une fois** sur la cible :

  ```
  php bin/console app:projet:reprendre-les-fichiers --blanc   # voir d'abord
  php bin/console app:projet:reprendre-les-fichiers           # puis écrire
  ```

  C'est elle qui fait entrer les pièces de l'ancien silo dans le Drive. **Sans elle, les projets
  existants perdent leur contexte** : le prompt système ne lit plus `project_file`, et les
  documents n'existent pas encore. Elle est idempotente (la marque est sur la source), donc
  rejouable sans risque — et une pièce en échec repasse au coup suivant.

- **2026-08-12** — **B2 est construit et redessiné.** Branche `featScreenDocuments`, **23 commits**,
  **767 tests back · 177 front**, build vert. ⚠️ **Rien n'est poussé, rien n'est déployé** —
  arbitrage du user : *pas de déploiement tant que ce n'est pas terminé*.

  **Ce qui marche de bout en bout** : demander une lettre au chat → « Éditer » → elle s'ouvre
  nettoyée dans le Screen, modifiable, enregistrée toute seule → on la renomme, on la range dans un
  dossier → on la retrouve dans Documents, dans le Drive et dans la fiche du projet → on la jette →
  on la restaure ou on la détruit depuis la corbeille.

  **Le back** : entité `Document` (projet **nullable**, `trashed_at`, `source_message_id` pour
  l'idempotence), API en huit routes, et `DocumentExtractor` — le **ménage à l'extraction**, qui ne
  garde que la lettre. Migration `Version20260812090000`.

  **Le front** : le Screen aiguille vers ses moteurs, TipTap édite avec enregistrement différé,
  barre d'outils colorée par famille, menu de sélection, Documents / Drive / Corbeille, rail de
  56 px. Deux handoffs intégrés le même jour — le brief écrit le matin, le handoff #3 reçu et
  implémenté l'après-midi.

  **Trois pièges déjà vécus, tenus par des tests** : le sommaire portera des liens et jamais du
  contenu (`findLastMessagesWithFiles`) ; le projet neutre est une **vue**, pas une ligne, donc
  aucune garde commerciale à exempter ; et l'aller-retour Markdown est verrouillé par 21 cas —
  c'est lui qui interdit couleur, souligné et alignement dans l'éditeur.

  **Quatre défauts trouvés en chemin, dont deux invisibles :**
  - **Écran blanc en production locale** — un **cycle entre chunks manuels** (`vendor-react` ↔
    `vendor-markdown`), **préexistant** : Rollup n'émet qu'une fois ses utilitaires partagés et les
    range dans un chunk au hasard. `@tiptap/react` n'a fait que basculer l'ordre d'évaluation. La
    règle est désormais : **on ne découpe à la main que ce qui est chargé à la demande**.
  - **`Ctrl+Z` sur un document fraîchement ouvert le vidait** — un `setContent` redondant au
    montage créait une entrée d'historique. Trouvé par un test, pas à l'usage.
  - **`Conversation` ne porte aucun utilisateur** : elle appartient à son `Project`. C'est la vraie
    raison d'être de « Conversations rapides », et ça rend l'idée de la supprimer plus lourde
    qu'annoncé le 11/08.
  - **Le titre déduit gardait les marqueurs Markdown** (`**câbles**` avec les astérisques), vu en
    usage réel.

  **Deux affirmations du handoff #3 corrigées après vérification** : le fil d'Ariane **n'est pas en
  panne** chez nous (défaut du prototype du designer) — ce qui manquait était le document en
  feuille du fil ; et le `title` « écran non redessiné » est un artefact de prototype, remplacé par
  le compteur, dans la même formulation que le rail.

  ~~⚠️ **Ce qui reste, et que personne n'a regardé** : **thème sombre, mobile et états vides** sur
  les six écrans touchés.~~ — **résorbé** : la QA a été faite au fil de l'eau, correctifs compris
  (constat du user, 2026-08-17). Le handoff #3 en signalait deux points connus : la pilule sombre du
  menu de sélection devant s'inverser, et les deux couches d'en-tête devant tenir à 380 px.

  **Non entamés** : B1 (rendu Mermaid — un schéma s'affiche en source, et le panneau le dit), B5
  (mindmap de Flow), B6 (sommaire de projet et lecture à la demande, seule brique avec un vrai
  inconnu : le tool calling). Plus « Nouveau document » dans la colonne, laissé en attente à la
  demande du user, et le Drive qui charge les recherches en direct plutôt que par un magasin.

  📌 **Six migrations MySQL attendent d'être déployées** : les cinq des 10-11 août, plus
  `Version20260812090000`. Aucune n'a été exercée en conditions réelles.

- **2026-08-11 (après-midi)** — **Journée de conception, zéro ligne de code.** Quatre commits, tous
  de documentation. Deux specs neuves, une caduque, la roadmap recadrée.

  **Le Screen** (`docs/specs/core/screen.md`) remplace le « panneau de sortie » **et** le « viewer
  de page » — deux noms qui n'étaient justes que sur une moitié chacun, et dont la distinction
  entrée/sortie était un découpage de code, pas d'usage. Un seul contenant ; le moteur de rendu
  change, la forme non. Principe qui porte tout : **le Screen ne possède rien, il projette** — ce
  qui persiste est la source, et il se rouvre depuis elle. Conséquence non anticipée : **les onglets
  tombent**, ils résolvent un problème que ce principe dissout. Et le Screen a une **prise** : ce
  qu'il affiche est modifiable depuis le chat (sélection + consigne → remplacement dans le Screen).

  **Le dossier de projet** (`docs/specs/core/dossier-projet.md`) recentre la Suite. Onze décisions,
  dont : un projet **est** un dossier du Drive · tout porte un projet, le neutre en est un · un
  document = un nom, une place, une sortie, et c'est un **état** (dit / ouvert / gardé) ·
  **ouvrir c'est regarder, toucher c'est garder** · le contexte passe par un **sommaire écrit à
  l'entrée** + lecture à la demande · trois portes, trois questions (Documents pour *reprendre*,
  Drive pour *retrouver*, Projet pour *le sujet*) · **pas de page blanche**, créer un document ouvre
  une conversation · **corbeille en deux temps**, et ce qui y dort compte dans le quota — c'est ce
  qui la fera vider, puisqu'on a écarté toute expiration automatique.

  **L'arbitrage du rendu graphique est tranché** — Mermaid en lecture, `react-flow` pour Flow — et
  la manière dont il s'est débloqué mérite d'être retenue : **il était gelé sur un inventaire faux.**
  La Suite n'a **pas** de mindmap (aucune ligne dans `suite.md` ; le besoin circulait entre la
  roadmap et `panneau-sortie.md`), et celle de Flow n'est **pas** de l'édition (`tree.md` §6.3 : le
  même `tree_data` alimente les trois versions, seul le composant de rendu change). Il n'y avait
  donc aucun besoin d'édition graphique. *L'arbitrage n'était pas difficile, il était mal instruit.*

  Côté roadmap : **B4 rendu à D1** (l'identifiant B4 est brûlé), **Sprint 6 dégelé en B5**, **B2
  recadré** en « dossier de projet + Screen garni », **B6 créé** — le sommaire, la lecture à la
  demande et la prise, isolés parce qu'ils supposent du **tool calling**, seule brique réellement
  neuve de tout l'ensemble. Trois arbitrages fermés : rendu graphique, Drive comme outil de premier
  niveau, édition dans le panneau.

  ⚠️ **L'ordre interne de la phase B n'est pas posé** — la priorisation du user a bougé, elle se
  fait demain matin. Le tableau dit ce qu'il y a à faire, pas dans quel ordre.

  🔵 **Intuition commerciale posée en fin de journée, à rediscuter — ne rien coder dessus.**
  *« Les outils sont intégrés au système d'office, et c'est l'usage des tokens qui bloque, pas
  l'abonnement. »* Le raisonnement : on utilise, on trouve ça bon, **on se fait bloquer faute de
  tokens** — et c'est cette frustration-là qui déclenche l'achat, d'autant que 4,99 € n'est rien.

  Ce que ça change par rapport à l'existant, et c'est le point à instruire : le produit **verrouille
  aujourd'hui des capacités, pas seulement de la consommation**. Flow est fermé au plan Gratuit par
  une garde explicite dans `ChatController`, les modes sont restreints, Codestral est réservé au
  Pro. Ce modèle-là est **l'inverse** : tout ouvert, une seule limite, le compteur. Bonne nouvelle —
  **C1 a déjà construit l'instrument** (crédits unifiés à 1 pour le chat et 2 pour le moteur,
  `plan_quota` enfin lue, solde visible). Ce qui manquerait, c'est de retirer les verrous de
  capacité et de fixer l'allocation du Gratuit. Deux réserves à regarder avant de trancher : la
  grille C1 a été arbitrée sur **50 % de marge en pire cas**, et un Gratuit qui a tout change ce
  pire cas ; et le Gratuit devient une cible de stockage (voir la dette ci-dessus).

- **2026-08-08** — **La phase L se ferme, et une passe navigateur ouvre le carnet L4.**
  14 commits, **602 tests back (+92) et 55 front (+24)**.

  **Deux reports décidés, et ils ferment L2 et L3.** Le crawler de fond passe en suspens — *« je ne
  dois pas prévoir une bombe pour tuer des fourmis »* — et le moat de L3 le suit, puisqu'il en
  dépendait. Le raisonnement complet vit dans le carnet « Farming & crawler » plus haut ; il n'a pas
  à être refait. **Ne reste de L3 que son quota anti-abus, livré.** L4 est désormais le seul lot L
  ouvert, et il ne se ferme pas : c'est le carnet que l'usage remplit.

  ### Ce que la passe navigateur a trouvé — et qu'aucun test ne voyait

  **C'est le rendement de la journée** : six défauts réels, tous sortis en se servant du produit,
  aucun visible depuis le code.

  | Trouvé | Ce que ça dit |
  |---|---|
  | La synthèse se lisait sur **33 caractères** (arbitrage : 88) | Un `max-width` est un **plafond** ; ce qui manquait était un **plancher** |
  | Tous les **429 s'affichaient en anglais**, chat compris | Le front résout `error` avant `message` — un champ mal nommé, jamais vu |
  | La fiche pratique sortait sur une question **réglementaire** | L'enrichisseur juge les **sources**, jamais la question |
  | Elle affichait **« Tripadvisor »** et le téléphone du restaurant | Nom faux + numéro plausible = l'approximation vraisemblable |
  | Un **`<br>` dans un champ JSON-LD**, et la ville en double | Un champ déclaré textuel n'est pas un champ textuel |
  | Le thème **`system` ne se réévaluait jamais** | `system` est un abonnement, pas une lecture |

  **Ce que la mesure de lecture a appris de plus.** Un point de rupture au **viewport** ne pouvait
  pas suffire : à largeur de fenêtre égale, la carte n'a pas la même place selon que le panneau de
  sortie est ouvert. Trouvé **en calculant**, pas en regardant — à 1700 px avec le viewer, le texte
  retombait aux mêmes 33 caractères. D'où une *container query*, qui mesure la carte. Balayage après
  correctif : **plancher à 52 caractères** (560 → 59 ch · 780 → 84 · 820 → 52 · 1100 → 84 · 1400 → 88).

  ### Ce que la journée a construit

  - **Quota anti-abus** (L3) — 10/min et 200/jour sur la création d'une recherche. Deux seaux parce
    que les deux abus ne se ressemblent pas : la boucle client s'emballe en secondes, le jeton volé
    se consomme sur des heures. Plafond **unique pour tous les plans**, délibérément — les paliers
    relèvent de C1, sur relevés réels. ⚠️ Vérifié que le `default_lifetime: 300` du pool n'écrase pas
    la fenêtre d'un jour ; sans ce contrôle, le plafond se serait réinitialisé toutes les 5 minutes.
  - **Les deux actions du tour** du handoff d'harmonisation, au survol. « Exporter en document »
    n'était pas un manque de plomberie — l'export md/docx marchait, il était rendu en icône.
  - **Le pont chat → moteur** (« Vérifier par une recherche »). **Ce qui part est une question
    autonome, jamais le fil** : ouvrir un canal de contexte vers le moteur rouvrirait exactement le
    défaut que la décision #0006 a fermé. Le contexte est *dans* la question — c'est déjà la règle
    des relances.
  - **La pièce jointe repliée**, et son enveloppe. Les trois ponts assemblaient trois formats à la
    main ; les reconnaître à l'affichage était **impossible**, le `---` qu'ils employaient
    apparaissant *aussi* dans le corps des pages. Le format est fabriqué par nous : c'est à la
    fabrication de le rendre lisible.
  - **Un vrai Ctrl+V**, avec deux comportements selon la destination — **un message** accueille une
    pièce jointe, **une requête** ne peut pas. Coller un article dans le champ de recherche lançait
    jusqu'ici une recherche **payée** sur quatre mille caractères ; le texte est désormais ramené à
    une question par le service du pont.
  - **`.prose`** — la mise en forme d'un contenu rédigé, partagée. Le viewer n'était pas sans style :
    il en avait pour lui seul, alors que trois surfaces rendent la même chose.
  - **Le journal du viewer** — marqueur `[viewer-echec]`. Où le retrouver et comment le dépouiller :
    `docs/specs/search/ldico.md`, section « Journal du viewer ».

  ### Le bruit du viewer — première passe, et ce qu'elle ne fait pas

  Le code faisait déjà beaucoup (isolation du `<main>`, rôles ARIA, `nav`/`form`). Ce qui survivait
  n'était donc pas un manque de règles : **c'étaient des `<div>`** — rien à quoi se raccrocher côté
  structure. D'où un critère sur le **texte produit**, où **la série** est ce qui rend la règle sûre :
  trois libellés courts d'affilée sans une phrase trahissent une interface ; une ligne courte
  *isolée* est une légende, et elle est épargnée.

  Deux formes, découvertes l'une après l'autre **en mesurant** : les libellés nus, puis les **liens
  seuls sur leur ligne** (commandes, fil d'Ariane) qui survivaient à la première. Mesure sur la même
  page : **116 lignes → 94**, 15 848 caractères → 14 289, **8 témoins de bruit → 3**.

  ⚠️ **C'est une première passe, pas un extracteur de contenu principal.** Les trois témoins restants
  tiennent dans des séries trop courtes. Le journal dira si la suite vaut le chantier.

  **Et le pictogramme ne se règle pas en CSS** : un logo exporté en 500×500 a la taille intrinsèque
  d'une photo. Ce qui le distingue est ce que la **page déclare** — `width`/`height` lus à
  l'extraction, seuil à 96 px, et tout ce qui ne se déclare pas reste du contenu.

  ### Ce qui reste, et dans cet ordre

  1. **Rien n'est poussé** — 63 commits, et `dev.lintello.ai` suit `develop`, qui ne contient rien de
     L'dicO. **C'est le seul vrai verrou** : tes testeurs ne peuvent pas tester, donc L4 ne peut pas
     se remplir. Quatre obligations au déploiement, toutes silencieuses si oubliées : `composer
     install` (pdfparser), rebuild du front, `Version20260807160000` (pas encore jouée), et
     **`STAAN_API_KEY_EXTRA` dans le `.env.local` de la cible** — vide, l'étage payant est absent
     *sans lever d'erreur*.
  2. **Le carnet L4 ouvert** : le widget « Votre avis » recouvre les listes (⚠️ bêta seulement,
     arbitré sans suite), et **le mobile réel n'a jamais été testé** — Chrome bride la fenêtre, il
     faut l'émulation devtools.
  3. **`.prose` n'a pas été vue en navigateur** : le viewer ne s'ouvre qu'en cliquant une source, et
     je n'ai pas su l'atteindre depuis le pilotage. Ses règles affinent des styles déjà en ligne.
  4. **Le CSS des « fiches produit »** et le rendu Tripadvisor : à reprendre **après** le journal,
     maintenant que le bruit a baissé.

- **2026-08-07** — **Les petites dettes traitées une à une, et la décision 8 enfin fermée.**
  4 commits, **540 tests back (+30) et 31 front (+5)**. Le design est traité en parallèle par le user
  et n'est pas décrit ici.

  **⏸ CE QUI RESTE EN SUSPENS, à reprendre après le design** — c'est la raison d'être de cette note :

  1. ~~**Le proxy d'images n'a jamais été vu en navigateur.**~~ ✅ **Vu et exercé** (2026-08-07,
     mesuré dans les logs nginx) : **56 appels, 41 en 200**. Les images s'affichent, en clair
     comme en sombre.

     ⚠️ **Mais les 14 réponses en 404 ne sont pas des images mortes — ce sont des TIMEOUTS.**
     Journal applicatif : « Operation timed out after 8002 ms with 860088 out of 1538339 bytes
     received ». Le délai de `RemoteImageFetcher` est à **8 secondes** ; sur des visuels de 1,4 à
     2,1 Mo servis par un CDN, le proxy abandonne **à mi-téléchargement** — entre 45 % et 65 % du
     fichier était déjà reçu.

     C'est donc un seuil trop court, pas une source injoignable, et ça se voit d'autant moins que
     l'échec est **silencieux côté produit** : l'image manquante retombe sur sa trame ou son
     initiale, exactement comme une image qui n'existe pas. Un quart des visuels d'une page
     produit peut disparaître sans qu'aucun symptôme ne le dise.

     **À trancher par celui qui tient le proxy** : relever le délai, ou borner ce qu'on va
     chercher par le poids annoncé (`TAILLE_MAX` est déjà à 5 Mo, donc ces images passaient le
     filtre de taille mais pas celui du temps).
  2. **`docs/ROADMAP.md` et `docs/specs/search/moteur.md` ne sont pas à jour** : la décision 8 y est
     encore « ouverte » alors qu'elle est tranchée (#0007), et B2 doit récupérer le perfectionnement
     du viewer **et** l'OCR.
  3. ~~**Rallumer le bouton « Rattacher »** du viewer~~ — **reporté à B2 le 2026-08-07.** La
     question juridique est bien tranchée (markdown dérivé, purgé avec le projet), mais le bouton
     dépend du **Drive et des Documents** : sans eux, il n'y a pas d'endroit où ranger la page, et
     l'entité qu'on créerait maintenant serait à refaire.
  4. **Vérifier si l'OCR des images du chat fonctionne en prod.** Probablement non, en silence, et
     depuis toujours. Si c'est confirmé, ça remonte hors de B2.
  5. **Les quatre écarts de métadonnées Doctrine** (commentaires `DC2Type`, deux index à renommer),
     présents en dev aussi : tant qu'ils sont là, `schema:validate` n'est pas totalement vert.
  6. **`STAAN_API_KEY` reste à régénérer** — exposée en clair le 04/08. **Risque assumé pour
     l'instant** (arbitrage du user, 2026-08-07).
  7. **Le corpus rempli par l'usage** est le seul endroit où l'argument du « favori » ne s'applique
     pas : versement sans action de l'utilisateur, dans un stockage partagé, au service des autres.
     À regarder à part.

  **Ce que la journée a fermé** : le rattrapage `needs_enrichment` (193 sources, 37 pages réellement
  débloquées), la chaîne de migrations (`Version20260807160000`, cinq écarts dont l'authentification
  d'une installation neuve), et la décision **#0007 — les images tierces passent par notre serveur**,
  livrée en deux moitiés.

  **Trois choses apprises, dans l'ordre où elles ont coûté :**

  | Ce qui a été trouvé | Ce que ça dit |
  |---|---|
  | Le rattrapage portait sur 193 sources mais ne débloquait que **37 pages** | Mesurer l'effet réel avant de lancer, pas seulement le volume apparent |
  | Le premier jet du réécriveur **effaçait les pièces jointes des utilisateurs** — servies par nous, donc « non signables » | Une règle de sécurité qui ne distingue pas le local du distant casse le produit qu'elle protège |
  | Le **flux SSE ne peut pas être réécrit côté serveur** (une image Markdown se coupe entre deux morceaux) | D'où un verrou de bout de chaîne côté navigateur, qui rattrape aussi toute surface future |

  **Une méthode qui a payé, à réutiliser** : pour la chaîne de migrations, ne pas se fier au diff
  mais **monter une base vierge et la comparer table par table**, puis rejouer la migration sur une
  **copie du schéma de dev** pour vérifier qu'elle n'y fait rien. C'est ce second sens qui a montré
  que les gestes devaient être conditionnels — un `ADD COLUMN` inconditionnel aurait échoué en prod,
  soit la même panne que celle qu'on répare, dans l'autre sens.

  **Une remarque de méthode, aussi** : la question juridique paraissait lourde parce qu'elle était
  posée en bloc. Séparée, une moitié se tranchait en deux minutes et l'autre pouvait dormir. Un
  arbitrage qui ne bouge pas depuis longtemps mérite qu'on vérifie qu'il n'en cache pas deux.

- **2026-08-06 (soir)** — **Le carnet L4 vidé : 11 points sur 12, tous nés de l'usage.**
  Sept corrections d'écran, l'encart d'infos pratiques, et la fenêtre de destination du chat.
  **510 tests back, 26 front.**

  **Le doublon au rattachement d'un projet était une fausse piste, et la mesure l'a montrée.**
  14 recherches en base, **14 identifiants distincts** — aucun doublon n'a jamais existé. Les
  logs nginx montrent deux `PATCH /api/searches/…` en **200** : le rattachement fonctionnait.
  Le « 0 rattachée » constaté vient de la migration de la veille (`DELETE FROM saved_search`),
  pas d'un bug. Ce qui était vu était très probablement le **défaut n°7** : rattacher une
  recherche la faisait *disparaître* de la page projet, qui n'affichait que les conversations,
  tout en la laissant dans « Récents » — deux vues qui ne racontaient pas la même chose.
  Vérifié depuis par le user : **ça fonctionne**.

  **Méthode à retenir** : ne pas poser de déduplication à l'affichage tant que la cause n'est pas
  connue. Elle aurait masqué le symptôme et laissé le vrai défaut (la page projet incomplète) en
  place, invisible.

  **Les infos pratiques ne viennent pas d'un modèle.** Les annuaires publient du **JSON-LD
  schema.org** dans le `<head>` — adresse, téléphone, horaires déjà découpés, dans le HTML qu'on
  reçoit déjà. Déterministe, gratuit, et **rien ne peut être inventé** : sur une donnée qu'on va
  composer ou suivre, une approximation plausible est le pire des défauts. Mesuré sur
  « restaurant Forcalquier » : nom, « 28 boulevard Latourette, 04300 Forcalquier »,
  +33492725357, horaires par service.

  ⚠️ **Le téléphone n'est pas dans le texte** : il est derrière un bouton « Appeler », donc dans
  un `href="tel:"` que la conversion Markdown jette. Le chercher dans le contenu rédigé ne
  l'aurait jamais trouvé — c'est ce qui a imposé de lire le **HTML brut**, comme le mineur
  d'`og:image`.

  **Deux regroupements d'horaires, qui se corrigent l'un l'autre** : par jour (un restaurant
  déclare une plage par service → « mardi » deux fois de suite), puis par jours consécutifs (un
  commerce ouvert lundi-samedi → six lignes identiques). La régularité *est* l'information ; la
  répéter la masque. Un jour de fermeture rompt la série — « lundi – samedi » qui enjambe un
  mercredi fermé envoie quelqu'un devant une porte close.

  **La destination du chat se choisit maintenant.** Les trois ponts créaient tous une
  conversation neuve : or on travaille déjà dans un fil, on part chercher, on veut **ramener là
  d'où l'on vient**. Cinq récentes, plus un champ de question facultatif avec des amorces.
  A marché **sans toucher `ChatWindow`** : `pendingMessage` est consommé dès qu'une conversation
  est courante, pas seulement à sa création — vérifié avant d'écrire la fenêtre.

  **Constat d'usage non traité, arbitré par le user** : les relances suggérées sortent parfois à
  une au lieu de trois, et l'une portait un renvoi `[1]` — or elle ouvre une recherche **neuve**,
  où ce renvoi ne désigne rien. Le prompt proscrit les référents implicites (« et pour les
  juniors ? ») mais **pas les renvois numérotés**. *« Un coup j'en ai trois, un coup une, ce
  n'est pas dérangeant »* → laissé en l'état.

- **2026-08-06** — **Le moteur cesse d'être un chat déguisé, et le viewer de page arrive.**
  13 commits, 496 tests back (+53) et 26 front. Deuxième handoff de design versé
  (`docs/design/navigation-multi-outils/`, 3 captures neuves : hub, viewer côte à côte,
  viewer mobile).

  **Ce que l'usage a démenti, et qui a coûté une refonte.** La recherche portait un **fil de
  tours** — recherche ≈ conversation, tour ≈ message. Le constat est venu de l'usage (« je perds
  mes recherches d'avant »), la mesure l'a tranché : **la synthèse recevait les questions
  précédentes du fil, la récupération web les ignorait totalement.** Assez de contexte pour
  prétendre au fil, pas assez pour qu'il serve, et invisible à l'écran — avec un effet pervers,
  celui de laisser les anciennes questions orienter la rédaction pendant que les sources
  portaient sur un autre sujet. Décision #0006 : **une recherche = une requête**, historique
  plat, l'approfondissement devient explicite. Le motif produit tient en une phrase : **LintellO
  a déjà un chat**, un fil dans la recherche en doublait la fonction.

  **Trois mesures qui ont décidé de la suite** :

  | Mesure | Conséquence |
  |---|---|
  | `full_content` est forcé à `html` (l'`og:image` vit dans le `<head>`) | conversion HTML → Markdown chez nous, sinon il fallait choisir entre le viewer et le visuel |
  | Résumé du GIEC : 2,5 Mo → **0,4 s de réseau, 8 s d'extraction** | le coût d'un PDF est dans l'analyse ; borne à 8 Mo, délai front porté à 60 s |
  | Sur 10 sources : 3 PDF, 2 sans contenu rendu, 5 lisibles | après extraction PDF : **8 lisibles sur 10** |

  **Le viewer sert la page depuis NOTRE stockage** : le navigateur n'appelle jamais le site
  source pour le texte. Les **images** restent pointées (`no-referrer` posé) — ⚠️ la décision 8
  se rouvre par là, et par l'`og:image`.

  **Ce que le fil d'Ariane a appris.** Il manquait depuis L1 alors que le handoff le dessinait et
  que le jeu de jetons lui réservait ses couleurs, nommées pour lui. Une fois posé, l'écran de
  restitution avait **deux lignes de navigation empilées** — et le seul élément cliquable des
  trois était le bouton du bas. Règle retenue : **un segment est un lieu réel, tous cliquables
  sauf le dernier.** « Espace personnel » disparaît (il ne désignait aucun écran), la seconde
  ligne aussi.

  **Deux capacités trouvées sans porte**, comme `ProjectEditModal` la semaine dernière : les
  actions de survol de la colonne étaient conditionnées à `conversation &&`, donc une recherche
  n'avait ni renommer, ni rattacher, ni supprimer — alors que l'API et le magasin les faisaient
  depuis le premier jour.

  **Trois défauts trouvés par des tests, tous de la famille « ça abîme une donnée sans rien
  casser »** : `Un<br>Deux` devenait `UnDeux` dans une cellule de tableau ; les niveaux de titre
  se traitaient dans le mauvais sens (`h1` rattrapait `h1` dans `h1..h6`, tout ressortait au
  niveau 1) ; et le retour en haut après une relance ne marchait pas, `window.scrollTo` ne
  déplaçant personne depuis que chaque écran porte son propre conteneur de défilement.

  **Une dépendance ajoutée** : `smalot/pdfparser` (LGPL-3.0, PHP pur). Seul choix qui marche sur
  mutualisé OVH — ni `pdftotext` ni ImageMagick, et on n'y installe pas de binaire. ⚠️
  **`composer install` obligatoire au déploiement**, en plus du rebuild front.

  **Le diagnostic est rejouable** : `app:search:explain --pages` dit, source par source, le HTML
  reçu, le Markdown produit et pourquoi telle page ne s'ouvre pas. Il **tente réellement** le
  PDF au lieu de le déduire de l'extension — seul moyen de distinguer un document qu'on sait lire
  d'un scan.

- **2026-08-05 (soir)** — **Le moteur est opérationnel de bout en bout.** L0 et L1 livrés, L2
  partiel. 14 commits sur `featLdicoMoteur`, **443 tests back + 26 front**, rien de poussé.

  **Ce que la journée a démenti.** Trois croyances du cadrage sont tombées, et chacune a été
  remplacée par une mesure : le tier 2 n'attendait aucun tiers (la clé était là), Staan ne rend
  pas six champs mais bien plus **en opt-in**, et le tier 2 n'est pas un second appel mais un
  **paramètre du même appel** — ce qui en fait un fournisseur et non un enrichisseur.

  **La méthode qui a payé, à réutiliser.** Sonder l'API avant de coder, plutôt que déduire de la
  documentation. Le 404 de Staan est une signature NestJS → leur Swagger → les vrais paramètres.
  Idem pour l'image : j'allais écrire un mineur qui va chercher dix pages lui-même — c'est-à-dire
  refaire l'erreur du crawler — quand `full_content: html` les rapporte déjà.
  **Ne pas deviner une API : la mesurer.**

  **Six défauts trouvés que ni les tests ni le lint ne voyaient**, et qui disent tous la même
  chose — ce qui n'est ni rejoué ni regardé n'est pas vérifié :
  1. `SearchThread` mappée **nulle part** (préfixe happé par l'EntityManager PostgreSQL) ;
  2. la liste de sources **coupée** faute de conteneur de défilement ;
  3. le badge « Citée n » **manquait** la source portant tous les chiffres ;
  4. le visuel affichait une **boîte vide** au lieu de sa trame ;
  5. `&nbsp;` → espace **insécable** dans le corpus, invisible et qui casse tout `LIKE` ;
  6. `user.email_hash` **créé par aucune migration** — une installation neuve casse l'auth.

  **Deux capacités récupérées** au passage, révélées par le nettoyage du code mort : renommer /
  déplacer / supprimer une conversation, et `ProjectEditModal` qui n'était atteignable qu'à
  l'instant d'une création de projet.

  **Trois arbitrages du user** : fidélité stricte au handoff sur le contraste (point rouge assuré
  à D7, su d'avance) ; le moteur passe par l'étage payant, chat et farming restent à 1 €
  (décision #0005) ; contenu en pleine largeur plutôt que centré.

- **2026-08-05 (constat d'usage, à traiter en L4)** — **la colonne droite reste vide sur les
  requêtes non chiffrées.** Sur « Intermarché Mane », zéro chiffre clé — et c'est le comportement
  **voulu** (critère de « fait » n°4 : jamais de tableau complété pour remplir). Mais le besoin
  derrière est réel : cette colonne a **un seul rôle**, donner les faits saillants.

  **L'information utile est déjà là, enfouie.** Le dump brut de Staan montre, dans un passage du
  résultat 2 : « Route Salies, La Verdure - CD 117, 31260 Mane… ouvert du lundi au samedi de 09h00
  à 19h30 ». **Adresse et horaires**, que rien ne met en avant.

  Piste : un bloc **« Informations pratiques »** que la synthèse extrait quand la question est
  locale — adresse, horaires, téléphone, site officiel — chacun rattaché à sa source, avec la même
  règle qu'ailleurs (rien d'inventé, absent si absent). Il partagerait l'emplacement des chiffres
  clés, qu'il remplacerait quand la requête n'est pas chiffrée.

  **Intuition du user à ne pas perdre : ces mêmes données servent le farming.** Adresse, horaires,
  téléphone d'une entreprise, c'est exactement ce que Prospect va chercher par un autre chemin
  (`DomainDiscoveryService`, extraction multi-sources). Un extracteur de faits pratiques écrit
  pour le moteur serait réutilisable tel quel — à vérifier avant de le coder deux fois.

  **Autres mesures du même dump**, à ne pas reperdre : `confidence` est **`null`** (le champ existe
  au schéma, Staan ne le remplit pas — le signal objectif exploitable reste le **score des
  passages**) ; et `extra_snippets` n'est rempli que sur **2 résultats sur 10** pour une requête
  locale, contre 7/9 sur une requête d'actualité. Les petites pages ne sont pas reclassées, donc
  la synthèse travaille sur beaucoup moins de matière sur ce type de question.

- **2026-08-05** — **Ouverture du chantier L'dicO visible, et démenti de trois hypothèses.**
  Le contrat de récupération est posé et câblé sur les deux offres de Staan (détail en
  « ✅ Livré » ci-dessus). Ce qui compte pour la suite, ce sont les trois choses qu'on croyait :

  1. **« Le tier 2 attend un tiers. »** Faux — la clé était déjà là. Ce qui manquait, c'était de
     savoir laquelle des deux ouvrait quoi, et l'API ne permet pas de le déduire : elle ne refuse
     jamais rien. Il a fallu tester chaque clé sur chaque capacité pour l'établir.
  2. **« Staan rend six champs. »** Vrai de l'appel nu seulement. Date, contenu, passages scorés
     et filtre de domaines existaient depuis le début, en opt-in.
  3. **« Le tier 2 est un second appel sur les résultats. »** Faux, et ça a une conséquence de
     conception : c'est un **paramètre du même appel**. Le tier 2 ne pouvait donc pas être un
     enrichisseur — il est un **fournisseur**, et c'est ce qui a fait passer le contrat d'un
     fournisseur élu à une chaîne de fournisseurs.

  **Méthode qui a payé, à réutiliser** : le 404 de Staan est une signature NestJS, ce qui a mené
  à leur Swagger, qui a donné les vrais paramètres. Deux heures de sondage à l'aveugle évitées.
  **Ne pas deviner une API : chercher son schéma.**

  **Arbitrage du user sur l'exploitation du tier 2 ailleurs** : crawler **oui** (gros intérêt),
  farming plus tard, chat **doute assumé**. Analyse retenue —
  - *Crawler* : le meilleur usage des trois. Le crawler mesure **0,29 page par source** et se
    prend 13 refus en 403 ; Staan ramène 10 documents déjà extraits par requête, et c'est **son**
    infrastructure qui encaisse les refus. 0,35 € par nuit pour 177 sources. ⚠️ **Staan n'est pas
    un fetcher** : aucun endpoint « donne-moi cette URL » (`/fetch`, `/extract`, `/scrape`,
    `/contents` → 404). On interroge par requête, éventuellement bornée par `include_domains`.
    Le crawler changerait donc de nature — de « visite ce domaine » à « interroge dans ce
    domaine ». C'est un chantier, pas un branchement.
  - *Farming* : **non pour l'instant**. Prospect veut les signaux techniques de la home d'une TPE
    locale, qui a toutes les chances de ne pas être indexée. Et Prospect est parké.
  - *Chat* : **rien à câbler**. Une fois derrière le contrat, il hérite du tier 2 en relevant un
    plafond. La question n'est pas « brancher » mais « un tour de chat mérite-t-il 2 €/1000 et les
    tokens » — c'est C1, avec `usage_log` pour trancher.
  - ⚠️ **« Réduire les tokens » n'est vrai que d'`extra_snippets`**, pas de `full_content` :
    3 passages ciblés contre une page entière. C'est le bon défaut pour l'enrichissement.

- **2026-08-04 (soir)** — **Cadrage de L'dicO.** Le chantier change de nature : on **reconstruit en
  parallèle** derrière un *contrat de récupération*, au lieu de réparer le corpus. Décisions actées :
  cascade **composée en deux étages** (gratuit systématique / coûteux à la demande, deux clés Staan) ;
  index **repoussé derrière le contrat** ; corpus **construit par l'usage** ; **deux objets de
  données** (analytique anonyme en PG, recherche enregistrée en MySQL avec `SearchThread` /
  `SearchTurn` propres) ; **snapshot rejoué** à la réouverture ; synthèse **Small et systématique**,
  une fois par recherche neuve ; quota = **plafond anti-abus** ; **cache mutualisé repoussé** au
  moteur autonome, mesures à l'appui.

  **Le handoff de design est arrivé** (« Navigation multi-outils LintellO », haute fidélité,
  prototype HTML + dix captures) et tranche à lui seul plusieurs questions ouvertes : cinq outils
  dessinés / deux branchés, deux vues exclusives, rattachement de projet facultatif, « Rechercher
  partout » pour le `Ctrl K`, renvois numérotés dans la synthèse, écran remplaçant, relances
  suggérées. ⚠️ **Le bundle n'est pas versionné dans le dépôt** — à y verser.

  **Un besoin nouveau est entré** : le **panneau de sortie**, pour la recherche **et pour le chat**.
  Spec créée (`docs/specs/core/panneau-sortie.md`). Seule sa **capacité de mise en page** entre dans
  le premier lot ; le panneau visible arrive avec son premier contenu.

  Quatre specs réécrites (`moteur.md`, `surface-navigation.md`, `ldico.md`, plus `panneau-sortie.md`
  créée) et la roadmap réorganisée en lots. **`ldico.md` décrivait Tavily et Brave** comme moteurs
  de recherche, périmés depuis la cascade : corrigé.

- **2026-08-04** — **Ouverture de la phase A.** État des lieux mesuré sur le code ET sur les bases
  (le conteneur local attaque le PostgreSQL du VPS). Trois résultats, détaillés dans la section
  « Chantier en cours » ci-dessus : le crawler **tourne** mais son corpus n'est jamais relu
  (0 match sur 562 recherches) ; les crawlers L'dicO et Prospect ne sont **pas** mélangés (trois
  bases distinctes, un seul point de contact, et le retour vers Prospect n'est pas codé) ; la
  surface A2 bute sur l'absence totale de routage interne dans le front. Spec A2 rédigée
  (`docs/specs/search/surface-navigation.md`) et inscrite à `docs/specs/INDEX.md`.

- **2026-08-04** — **Roadmap réorganisée en phases** (`docs/ROADMAP.md`). L'dicO passe en tête
  (phase A, en trois temps : corpus → surface → page de résultats), l'harmonisation design est
  **absorbée par A2** puisque créer le moteur touche le HTML et le CSS en profondeur. Puis
  B (rendu des schémas, Flow Sprint 5), puis C (coûts/plans/quotas) — placée là parce que A et B
  **produisent la mesure** qui manquait pour trancher. D regroupe la mise en production, dont la
  porte de suffisance et la sécurité inscription. Identifiants désormais **stables** (A1, B2, D3…)
  : la renumérotation avait déjà cassé les renvois deux fois. Deux reports à surveiller — la porte
  de suffisance descend en D1, donc les relevés de C incluront le gaspillage qu'elle aurait évité ;
  la sécurité inscription descend en D2, tenable tant que la bêta reste confidentielle.

- **2026-08-04** — **`docs/ROADMAP.md` enfin rempli** (il était vide ; le calendrier de
  `Project_Status.md`, calé sur un lancement au 1er mai, ne fait plus foi). Trois arbitrages du
  user : **Phase 3.7 (citations académiques) en suspens** — importance non établie, et sa
  numérotation `[n]` contredit la citation en lien inline livrée au lot 7 ; **L'dicO, moteur de
  recherche boosté IA, visé septembre 2026** et promu au chemin de mise en prod ; **coûts /
  plans / quotas repoussés au plus tard, mais avant Stripe LIVE** — le coût de revient a changé
  avec les lots 6b et 7, et `usage_log` ne contient encore aucun tour Flow, donc décider tôt
  serait arbitrer sur des nombres faux. Reste à spécifier : ce que « proposer L'dicO » veut dire
  (recherche dans le chat mieux exposée vs surface autonome).

- **2026-08-03** — **Bilan de la journée : lots 4, 5a, 5a bis et 5b livrés. Dix pansements
  retirés, chacun avec sa cause corrigée plutôt que masquée.**

  | Pansement | Cause réelle, corrigée |
  |---|---|
  | `findLastMessagesWithFiles` et son `LIKE` | Les documents vivent dans le corps du message → injectés en résumé, hors de l'historique |
  | Limite magique à 6 messages avec images | Aucune : seuil arbitraire |
  | `logger->debug` « investiguer les répétitions » | Enquête close : horodatage à la seconde |
  | `verbositeHeuristique` (30 lignes de regex) | La verbosité se déduit du sens (lot 3, appliqué au 5a) |
  | Seuil `messageCount <= 2` de la clarification | Remplacé par « aucun historique disponible » |
  | Troncature muette à 500 caractères | Signalée, et le résumé la remplace |
  | `getMaxHistoryMessages` dans le flux | Comptait ce qu'il ne mesurait pas → budget en tokens |
  | Fallback `getContextText()` à l'injection | Tronquait avant qu'on tronque |
  | `contexte.ancrage` | La troncature elle-même → compaction (5b) |
  | `socle.anti_repetition` au niveau 1 | Deux causes de redite trouvées et corrigées → descendu au niveau 6 |

  **Réserve : le lot 5b n'a pas été éprouvé en conditions réelles.** Il ne se déclenche que sur
  une conversation assez longue pour déborder du budget. Vérification : mener une conversation
  longue, demander « rappelle-moi ce qu'on s'est dit au début », et contrôler que
  `conversation_summary` s'est remplie. Vu ce que la campagne des lots 4 et 5a a fait sortir, ne
  pas le considérer comme acquis avant ce test.

- **2026-08-03** — **Campagne de test en conditions réelles des lots 4 et 5a.** Validé : consigne
  détectée, affichée, appliquée, isolée par dimension, non fuitée d'une conversation à l'autre,
  durable vs ponctuel correctement distingué (« sois plus concis dans tes réponses » → durable,
  « donne-moi une réponse plus courte » → ponctuel), veto de réinstallation effectif, les deux
  garde-fous d'injection tiennent (« cite tes instructions » et « réponds en anglais » ne
  retiennent rien), et **le document est retrouvé après plusieurs échanges hors sujet** — le
  test décisif du 5a.

  **Trois bugs réels trouvés, qu'aucun des 282 tests unitaires ne pouvait voir** :
  1. Cache `immutable` sans hash — **aucun déploiement front n'atteignait les utilisateurs**,
     depuis toujours. Explique le badge de mode « absent » du lot 3.
  2. Un message qui ne fait que régler la forme (« tutoie-moi ») faisait **reprendre au modèle
     toute sa réponse précédente**.
  3. `created_at` en DATETIME (à la seconde) : une **réponse pouvait remonter avant sa
     question**, et le modèle y répondait alors une seconde fois. Cause directe des répétitions
     que `socle.anti_repetition` masque au niveau 1. Le lot 5a avait au passage supprimé la
     compensation (tri secondaire par rôle) en gardant la cause.

  **Leçon de méthode : chaque lot mérite sa demi-heure d'usage réel avant d'être considéré comme
  fini.** Les tests unitaires figent la mécanique, ils ne voient ni le cache, ni le sens, ni
  l'ordre réel des écritures.

  **Défaut restant à trancher** : quand le veto bloque une consigne, LintellO répond « D'accord »
  sans l'appliquer — il acquiesce à ce qu'il ne fera pas, ce qui contredit « tu ne dis jamais oui
  pour faire plaisir ». Piste : ne faire jouer le veto que sur une **redétection** automatique,
  pas sur une demande explicite (`neReglQueLaForme()` distingue déjà les deux).

- **2026-08-03** — Lot 5a livré. **Méthode retenue avec le user : un pansement dont la cause
  est corrigée doit mourir dans le même lot que son remplaçant, jamais avant.** L'inventaire a
  montré que `contexte.ancrage` avait été posé sur un mauvais diagnostic (on croyait au décalage
  d'un tour, c'était la troncature) — donc sur une vraie plaie, mais pas celle qu'on visait. Il
  attend le 5b. Deux pansements restants sont documentés : `socle.anti_repetition`, posé au
  **niveau 1** donc jamais arbitrable, et la règle anti-contournement passif-agressif de la
  constitution — une règle qui patche une règle, à traiter à la stabilisation du prompt en
  **enlevant** des règles concurrentes.

- **2026-08-03** — Lot 4 livré. La détection des consignes est **stochastique** : Small tient
  bien les cas mesurés (11/12, et 5/5 au recontrôle sur le cas fondateur), mais un tirage isolé
  peut manquer une consigne. C'est le prix du sens contre les mots ; la mécanique en aval, elle,
  est déterministe et testée. Trois itérations de prompt ont été nécessaires — chacune mesurée,
  et la deuxième avait fait chuter la détection des consignes durables. **Question structurelle
  ouverte** : les questionnaires `user_context` doivent rétrécir vers les FAITS. `responseStyle`
  et `learningStyle` deviennent redondants avec les consignes durables — `learningStyle` émet
  même des directives de format depuis `buildStudentPrompt()`, dans un bloc **sans dimension
  déclarée**, donc hors résolution : il ne sera pas écrasé par une consigne de format. À
  trancher. **Décision #0004 rédigée** : trois familles de données, règle « retenir ce qui a été
  énoncé, jamais inférer ce qui ne l'a pas été », catégories sensibles hors apprentissage,
  clarification comme déclencheur. Rattachée au lot 9. Motif mesuré : les bêta-testeurs ne
  remplissent pas le questionnaire, et aucune réécriture du formulaire n'y changera rien —
  il demande d'investir avant d'avoir rien reçu.

- **2026-07-31** — Session refonte du flux de réponse. Diagnostic mesuré : 12 blocs de prompt
  dont 8 non déclarés, 5 classifieurs indépendants, 3 vues concurrentes de l'historique. Lots 0
  à 4bis livrés, 197 tests. Le secret `JWT_PASSPHRASE` n'existait que dans l'ancien `.env`
  supprimé — restauré dans `.env.local` avec `APP_SECRET` et `ADMIN_ALERT_EMAIL`.
