File size: 11,353 Bytes
1766da1
 
 
 
 
 
 
 
2b782d0
1766da1
 
 
 
 
 
 
 
 
 
 
 
5bb0965
 
 
92de89a
 
1766da1
 
 
 
 
 
 
 
 
 
92de89a
 
 
 
 
 
 
2b782d0
 
92de89a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1766da1
92de89a
 
1766da1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb9f9b6
1766da1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b782d0
1766da1
2b782d0
1766da1
 
 
 
 
 
 
 
 
 
 
 
 
c6da3d3
1766da1
 
 
 
e1b8452
 
 
5bb0965
e1b8452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5bb0965
e1b8452
 
 
2b782d0
 
 
e1b8452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5bb0965
e1b8452
5bb0965
e1b8452
 
 
 
 
 
 
 
 
 
 
bb9f9b6
e1b8452
bb9f9b6
e1b8452
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
# É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` :

```python
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` :

```python
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.

```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_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/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

```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/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` :

```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

`select_facts` accepte un argument optionnel
`type_order` :

```python
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.