Picarones / docs /explanation /narrative-engine.md
Claude
docs: zero out narrative debt (Phase 2 L4)
5bb0965 unverified

É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/domain/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

Un nouveau détecteur ne demande que deux fichiers à toucher. Le décorateur @register_detector se charge de l'enregistrement, du tri par priorité, et de l'alimentation de arbiter.DEFAULT_TYPE_ORDER.

1. Déclarer le type de fait

Dans facts.py, ajoutez une valeur à FactType :

class FactType(str, Enum):
    ...
    NEW_THING = "new_thing"

2. Implémenter et enregistrer le détecteur

Dans detectors.py, écrivez une fonction pure qui prend le dict benchmark_data et retourne une liste de Fact, puis décorez-la avec @register_detector :

from picarones.domain.narrative.facts import Fact, FactImportance, FactType
from picarones.domain.narrative.registry import register_detector


@register_detector(
    FactType.NEW_THING,
    priority=55,                          # entre STRATUM_COLLAPSE (50) et ERROR_PROFILE_OUTLIER (60)
    importance=FactImportance.HIGH,
)
def detect_new_thing(benchmark_data: dict) -> list[Fact]:
    ...

Le décorateur :

  • enregistre la fonction dans le registre central trié par priority ;
  • alimente automatiquement arbiter.DEFAULT_TYPE_ORDER (plus besoin d'éditer arbiter.py) ;
  • vérifie qu'aucun autre détecteur n'est déjà enregistré sur le même FactType (sinon ValueError) ;
  • laisse la fonction utilisable telle quelle (pour les tests unitaires qui l'appellent directement).

Conventions de priorité

Plus la valeur est petite, plus le fait remonte tôt en synthèse à importance égale. Les détecteurs builtin utilisent un pas de 10 pour laisser de la place :

Priority Type Question éditoriale
10 GLOBAL_LEADER_CER Qui gagne globalement ?
20 STATISTICAL_TIE Y a-t-il un ex-aequo ?
30 SIGNIFICANT_GAP À quel point l'écart est solide ?
40 STRATUM_WINNER Qui domine sur quel sous-corpus ?
50 STRATUM_COLLAPSE Qui s'effondre sur quoi ?
60 ERROR_PROFILE_OUTLIER Qui se trompe différemment ?
70 LLM_HALLUCINATION_FLAG Hallucinations VLM ?
80 ROBUSTNESS_FRAGILE Sensibilité aux dégradations ?
90 PARETO_ALTERNATIVE Y a-t-il un compromis coût/qualité ?
100 SPEED_WINNER Vitesse ?
110 COST_OUTLIER Coût aberrant ?
120 CONFIDENCE_WARNING Mise en garde sur la fiabilité.

Détails techniques

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.

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 :

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) :

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_narrative_engine.py (ou un nouveau fichier) :
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/reports/html/i18n/ pour les libellés d'interface.
  3. Ajoutez de.yaml dans picarones/reports/html/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

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/evaluation/synthetic.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

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 :

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

select_facts accepte un argument optionnel type_order :

from picarones.domain.narrative import build_synthesis
from picarones.domain.narrative.arbiter import select_facts, DEFAULT_TYPE_ORDER
from picarones.domain.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

Le test de traçabilité des nombres tolérait initialement 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_narrative_engine.py::test_every_number_in_synthesis_is_traceable plus le test test_anti_hallucination.py::TestTemplatesNoHardcodedLiterals échoueront sinon.