# Étendre le moteur narratif Ce guide explique comment ajouter un nouveau type de **fait détecté** à la synthèse factuelle en tête du rapport. ## Architecture ``` picarones/core/narrative/ ├── __init__.py # API publique + pipeline build_synthesis ├── facts.py # Modèle Fact, FactType, FactImportance, DetectorRegistry ├── detectors.py # 12 détecteurs (un par FactType) ├── arbiter.py # Tri par importance, non-redondance, anti-contradiction ├── renderer.py # Rendu str.format_map sur templates YAML └── templates/ ├── fr.yaml # Templates français (1 par FactType) └── en.yaml # Templates anglais ``` ## Ajouter un détecteur ### 1. Déclarer le type de fait Dans `facts.py`, ajoutez une valeur à `FactType` : ```python class FactType(str, Enum): ... NEW_THING = "new_thing" ``` ### 2. Implémenter le détecteur Dans `detectors.py`, ajoutez une fonction pure qui prend le dict `benchmark_data` (le JSON de résultats du rapport) et retourne une liste de `Fact`. Le détecteur ne doit **jamais lever d'exception** — le `DetectorRegistry` capte les erreurs en `logger.warning` mais c'est une protection, pas une excuse. ```python def detect_new_thing(benchmark_data: dict) -> list[Fact]: """Doc explicite : qu'est-ce qui déclenche ce fait ?""" # Exemple : flag les moteurs où une métrique X dépasse un seuil facts: list[Fact] = [] for engine in benchmark_data.get("engines") or []: if (engine.get("some_metric") or 0) > 0.5: facts.append(Fact( type=FactType.NEW_THING, importance=FactImportance.HIGH, payload={ "engine": engine["name"], "value": round(engine["some_metric"], 4), "value_pct": round(engine["some_metric"] * 100, 1), }, engines_involved=(engine["name"],), )) return facts ``` **Règle d'or anti-hallucination** : chaque champ que vous mettez dans `payload` doit être **calculé à partir de** valeurs présentes dans `benchmark_data`. Pas de constante ni de calcul invraisemblable. ### 3. Enregistrer dans la table Toujours dans `detectors.py`, ajoutez au dict `DETECTORS_BY_TYPE` : ```python DETECTORS_BY_TYPE = { ... FactType.NEW_THING: detect_new_thing, } ``` `register_default_detectors(registry)` parcourt ce dict et l'enregistre automatiquement. Aucune action supplémentaire requise. ### 4. Ajouter les templates FR/EN Dans `templates/fr.yaml` et `templates/en.yaml`, ajoutez une entrée par type, avec le nom de la valeur enum (ici `new_thing`) : ```yaml new_thing: >- Le moteur {engine} dépasse le seuil de la métrique X ({value_pct} %). ``` Les placeholders `{engine}`, `{value_pct}` etc. doivent **exactement** correspondre aux clés du `payload` du détecteur. Si vous oubliez un champ, le rendu utilisera `?` (et logguera un warning) plutôt que de crasher — mais les tests doivent attraper ça. ### 5. Ajuster l'arbitre si besoin Dans `arbiter.py`, deux choses à considérer : - **Ordre canonique** : ajoutez votre type dans `_TYPE_ORDER` à la position appropriée. Cet ordre départage les ex-aequo à importance égale et garantit le déterminisme. - **Paires complémentaires** : par défaut, l'arbitre supprime les doublons sur le même moteur. Si votre nouveau type est complémentaire d'un autre type pour le même moteur (ex. leader + speed), ajoutez la paire dans `_COMPLEMENTARY_PAIRS`. - **Règles anti-contradiction** : si votre fait peut contredire un autre (ex. Nemenyi vs Wilcoxon), implémentez la règle dans `_remove_contradictions`. ### 6. Tests Ajoutez au minimum : - Un test unitaire dans `tests/test_sprint19_narrative_engine.py` (ou un nouveau fichier) : ```python class TestNewThingDetector: def test_emits_when_threshold_crossed(self): data = _minimal_data(engines=[ {"name": "X", "some_metric": 0.7}, ]) facts = detect_new_thing(data) assert len(facts) == 1 assert facts[0].payload["engine"] == "X" def test_empty_when_under_threshold(self): data = _minimal_data(engines=[ {"name": "X", "some_metric": 0.3}, ]) assert detect_new_thing(data) == [] ``` - Le test global de traçabilité (`test_every_number_in_synthesis_is_traceable`) couvrira automatiquement votre détecteur dès que vous l'ajoutez à la synthèse. ## Ajouter une langue Pour ajouter une nouvelle langue (ex. allemand) : 1. Créez `templates/de.yaml` en copiant la structure de `fr.yaml` et en traduisant chaque entrée. 2. Ajoutez `de.json` dans `picarones/report/i18n/` pour les libellés d'interface. 3. Ajoutez `de.yaml` dans `picarones/report/glossary/` pour le glossaire. 4. Le code détecte automatiquement la langue via `load_glossary("de")`, `get_labels("de")`, et `_load_templates("de")` — aucun code à modifier. ## Tester votre changement ```bash pytest tests/ -q --tb=short picarones demo --output /tmp/demo.html --docs 8 # Ouvrir /tmp/demo.html et vérifier que la synthèse contient votre fait ``` Si la synthèse ne contient pas votre fait, vérifiez : 1. Que votre détecteur retourne bien quelque chose sur les données de démo (`grep -A 20 "def generate_sample_benchmark" picarones/fixtures.py`). 2. Que l'importance est suffisante (> `MEDIUM`) pour passer le filtre par défaut de l'arbitre. 3. Que votre type n'est pas en collision avec un autre déjà retenu pour le même moteur (cf. `_is_redundant`). --- ## Politique éditoriale (Sprint 23) L'arbitre départage les faits d'**égale importance** par un ordre canonique des types : c'est un choix éditorial qui répond à la question *« quand A et B sont aussi importants l'un que l'autre, lequel parle en premier ? »*. L'ordre par défaut est défini dans `arbiter.py` sous le nom `DEFAULT_TYPE_ORDER` : ```python DEFAULT_TYPE_ORDER = ( FactType.GLOBAL_LEADER_CER, # 1. Qui gagne globalement FactType.STATISTICAL_TIE, # 2. Y a-t-il un ex-aequo FactType.SIGNIFICANT_GAP, # 3. À quel point l'écart est solide FactType.STRATUM_WINNER, # 4. Qui domine sur quel sous-corpus FactType.STRATUM_COLLAPSE, # 5. Qui s'effondre sur quoi FactType.ERROR_PROFILE_OUTLIER, # 6. Qui se trompe différemment FactType.LLM_HALLUCINATION_FLAG, # 7. Hallucinations VLM FactType.ROBUSTNESS_FRAGILE, # 8. Sensibilité aux dégradations FactType.PARETO_ALTERNATIVE, # 9. Y a-t-il un compromis coût/qualité FactType.SPEED_WINNER, # 10. Vitesse FactType.COST_OUTLIER, # 11. Coût aberrant FactType.CONFIDENCE_WARNING, # 12. Mise en garde sur la fiabilité ) ``` **Hypothèse implicite** : un lecteur d'institution patrimoniale veut d'abord savoir *qui gagne* puis *à quel point cette victoire est solide*, avant de découvrir des considérations de coût ou de vitesse. Une équipe DevOps cherchant à industrialiser une chaîne aurait probablement l'ordre inverse — vitesse et coût d'abord, qualité ensuite. ### Surcharger l'ordre sans patcher le code Depuis le Sprint 23, `select_facts` accepte un argument optionnel `type_order` : ```python from picarones.core.narrative import build_synthesis from picarones.core.narrative.arbiter import select_facts, DEFAULT_TYPE_ORDER from picarones.core.narrative.facts import FactType # Réordonnancement : on remonte vitesse et coût avant qualité. custom = ( FactType.SPEED_WINNER, FactType.COST_OUTLIER, FactType.PARETO_ALTERNATIVE, FactType.GLOBAL_LEADER_CER, # ... compléter avec les autres types ; ceux qui manquent sont # relégués à la fin sans crash. ) facts = detect_all(benchmark_data) selected = select_facts(facts, max_facts=5, type_order=custom) ``` Cas d'usage typiques : - **Atelier MOOC** : promouvoir `STRATUM_COLLAPSE` et `ERROR_PROFILE_OUTLIER` en tête pour mettre l'accent sur la lecture diagnostique des erreurs. - **Comité technique** : promouvoir `CONFIDENCE_WARNING` en tête pour forcer la discussion sur la fiabilité avant les classements. - **Évaluation budgétaire** : promouvoir `COST_OUTLIER` et `PARETO_ALTERNATIVE` en tête. ### Règle anti-hallucination renforcée (Sprint 23) Avant le Sprint 23, le test de traçabilité des nombres tolérait deux littéraux non-traçables au payload (`95` pour le seuil de l'IC, `100` comme tolérance numérique). Cette whitelist est désormais vide : - Le seuil de confiance est propagé via `confidence_level` dans le payload des `Fact` de type `CONFIDENCE_WARNING`. - L'unité du coût (`/1000 pages`) est propagée via `cost_unit_pages` dans `PARETO_ALTERNATIVE` et `COST_OUTLIER`. **Si vous ajoutez un détecteur dont le template référence un nombre constant** (ex. *« seuil α = 0,05 »*), vous devez **systématiquement** le mettre dans le `payload`. Le test `test_sprint19_narrative_engine.py::test_every_number_in_synthesis_is_traceable` plus le test `test_sprint23_anti_hallucination.py::TestTemplatesNoHardcodedLiterals` échoueront sinon.