Spaces:
Sleeping
sprint23: intégrité anti-hallucination du moteur narratif
Browse filesLe test Sprint 19 ``test_every_number_in_synthesis_is_traceable``
tolérait une whitelist ``{"95", "100"}`` de littéraux non-traçables au
payload des Fact. Cette whitelist est maintenant vide ; tout nombre
apparaissant dans la synthèse rendue doit venir du payload d'un Fact
détecté, sans exception.
Changements
-----------
- ``CONFIDENCE_WARNING`` propage ``confidence_level=95`` dans son
payload ; les templates FR/EN référencent ``{confidence_level} %``
au lieu d'écrire "95 %" en dur.
- ``PARETO_ALTERNATIVE`` et ``COST_OUTLIER`` propagent
``cost_unit_pages=1000`` ; les templates FR/EN référencent
``€/{cost_unit_pages} pages`` au lieu de "/1000 pages".
- ``select_facts(facts, type_order=...)`` accepte désormais une
surcharge de l'ordre canonique des types (politique éditoriale).
L'ancienne constante ``_TYPE_ORDER`` reste exportée comme alias de
``DEFAULT_TYPE_ORDER`` pour rétro-compatibilité.
Tests (+14, soit 1256 passing au total)
---------------------------------------
- ``tests/test_sprint23_anti_hallucination.py`` :
* payloads exposent les nouveaux champs (3 tests)
* templates FR/EN ne contiennent plus 95/1000 en dur (4 tests)
* pipeline complet rend sans placeholder non substitué et reste
traçable avec whitelist vide (4 tests)
* stabilité du bootstrap entre seeds — borne ±0,5 pp à n_iter=1000
pour 20 documents (2 tests). Si ce test échoue à l'avenir,
passer ``n_iter=5000``.
* ``select_facts`` respecte un ``type_order`` custom (3 tests).
- ``tests/test_sprint19_narrative_engine.py`` :
``_TEMPLATE_CONSTANTS = frozenset()``.
Documentation
-------------
``docs/developer/narrative-engine.md`` gagne une section "Politique
éditoriale" qui documente l'ordre par défaut, ses hypothèses
implicites, et la procédure de surcharge via ``type_order``.
https://claude.ai/code/session_01L4RGWMrAajn5ZEFgTKjA5P
- docs/developer/narrative-engine.md +87 -0
- picarones/core/narrative/arbiter.py +26 -6
- picarones/core/narrative/detectors.py +7 -0
- picarones/core/narrative/templates/en.yaml +3 -3
- picarones/core/narrative/templates/fr.yaml +3 -3
- tests/test_sprint19_narrative_engine.py +7 -4
- tests/test_sprint23_anti_hallucination.py +275 -0
|
@@ -161,3 +161,90 @@ Si la synthèse ne contient pas votre fait, vérifiez :
|
|
| 161 |
par défaut de l'arbitre.
|
| 162 |
3. Que votre type n'est pas en collision avec un autre déjà retenu pour
|
| 163 |
le même moteur (cf. `_is_redundant`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
par défaut de l'arbitre.
|
| 162 |
3. Que votre type n'est pas en collision avec un autre déjà retenu pour
|
| 163 |
le même moteur (cf. `_is_redundant`).
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## Politique éditoriale (Sprint 23)
|
| 168 |
+
|
| 169 |
+
L'arbitre départage les faits d'**égale importance** par un ordre canonique
|
| 170 |
+
des types : c'est un choix éditorial qui répond à la question *« quand A et
|
| 171 |
+
B sont aussi importants l'un que l'autre, lequel parle en premier ? »*.
|
| 172 |
+
|
| 173 |
+
L'ordre par défaut est défini dans `arbiter.py` sous le nom
|
| 174 |
+
`DEFAULT_TYPE_ORDER` :
|
| 175 |
+
|
| 176 |
+
```python
|
| 177 |
+
DEFAULT_TYPE_ORDER = (
|
| 178 |
+
FactType.GLOBAL_LEADER_CER, # 1. Qui gagne globalement
|
| 179 |
+
FactType.STATISTICAL_TIE, # 2. Y a-t-il un ex-aequo
|
| 180 |
+
FactType.SIGNIFICANT_GAP, # 3. À quel point l'écart est solide
|
| 181 |
+
FactType.STRATUM_WINNER, # 4. Qui domine sur quel sous-corpus
|
| 182 |
+
FactType.STRATUM_COLLAPSE, # 5. Qui s'effondre sur quoi
|
| 183 |
+
FactType.ERROR_PROFILE_OUTLIER, # 6. Qui se trompe différemment
|
| 184 |
+
FactType.LLM_HALLUCINATION_FLAG, # 7. Hallucinations VLM
|
| 185 |
+
FactType.ROBUSTNESS_FRAGILE, # 8. Sensibilité aux dégradations
|
| 186 |
+
FactType.PARETO_ALTERNATIVE, # 9. Y a-t-il un compromis coût/qualité
|
| 187 |
+
FactType.SPEED_WINNER, # 10. Vitesse
|
| 188 |
+
FactType.COST_OUTLIER, # 11. Coût aberrant
|
| 189 |
+
FactType.CONFIDENCE_WARNING, # 12. Mise en garde sur la fiabilité
|
| 190 |
+
)
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
**Hypothèse implicite** : un lecteur d'institution patrimoniale veut
|
| 194 |
+
d'abord savoir *qui gagne* puis *à quel point cette victoire est solide*,
|
| 195 |
+
avant de découvrir des considérations de coût ou de vitesse. Une équipe
|
| 196 |
+
DevOps cherchant à industrialiser une chaîne aurait probablement l'ordre
|
| 197 |
+
inverse — vitesse et coût d'abord, qualité ensuite.
|
| 198 |
+
|
| 199 |
+
### Surcharger l'ordre sans patcher le code
|
| 200 |
+
|
| 201 |
+
Depuis le Sprint 23, `select_facts` accepte un argument optionnel
|
| 202 |
+
`type_order` :
|
| 203 |
+
|
| 204 |
+
```python
|
| 205 |
+
from picarones.core.narrative import build_synthesis
|
| 206 |
+
from picarones.core.narrative.arbiter import select_facts, DEFAULT_TYPE_ORDER
|
| 207 |
+
from picarones.core.narrative.facts import FactType
|
| 208 |
+
|
| 209 |
+
# Réordonnancement : on remonte vitesse et coût avant qualité.
|
| 210 |
+
custom = (
|
| 211 |
+
FactType.SPEED_WINNER,
|
| 212 |
+
FactType.COST_OUTLIER,
|
| 213 |
+
FactType.PARETO_ALTERNATIVE,
|
| 214 |
+
FactType.GLOBAL_LEADER_CER,
|
| 215 |
+
# ... compléter avec les autres types ; ceux qui manquent sont
|
| 216 |
+
# relégués à la fin sans crash.
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
facts = detect_all(benchmark_data)
|
| 220 |
+
selected = select_facts(facts, max_facts=5, type_order=custom)
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
Cas d'usage typiques :
|
| 224 |
+
|
| 225 |
+
- **Atelier MOOC** : promouvoir `STRATUM_COLLAPSE` et
|
| 226 |
+
`ERROR_PROFILE_OUTLIER` en tête pour mettre l'accent sur la lecture
|
| 227 |
+
diagnostique des erreurs.
|
| 228 |
+
- **Comité technique** : promouvoir `CONFIDENCE_WARNING` en tête pour
|
| 229 |
+
forcer la discussion sur la fiabilité avant les classements.
|
| 230 |
+
- **Évaluation budgétaire** : promouvoir `COST_OUTLIER` et
|
| 231 |
+
`PARETO_ALTERNATIVE` en tête.
|
| 232 |
+
|
| 233 |
+
### Règle anti-hallucination renforcée (Sprint 23)
|
| 234 |
+
|
| 235 |
+
Avant le Sprint 23, le test de traçabilité des nombres tolérait deux
|
| 236 |
+
littéraux non-traçables au payload (`95` pour le seuil de l'IC, `100`
|
| 237 |
+
comme tolérance numérique). Cette whitelist est désormais vide :
|
| 238 |
+
|
| 239 |
+
- Le seuil de confiance est propagé via `confidence_level` dans le
|
| 240 |
+
payload des `Fact` de type `CONFIDENCE_WARNING`.
|
| 241 |
+
- L'unité du coût (`/1000 pages`) est propagée via `cost_unit_pages`
|
| 242 |
+
dans `PARETO_ALTERNATIVE` et `COST_OUTLIER`.
|
| 243 |
+
|
| 244 |
+
**Si vous ajoutez un détecteur dont le template référence un nombre
|
| 245 |
+
constant** (ex. *« seuil α = 0,05 »*), vous devez **systématiquement**
|
| 246 |
+
le mettre dans le `payload`. Le test
|
| 247 |
+
`test_sprint19_narrative_engine.py::test_every_number_in_synthesis_is_traceable`
|
| 248 |
+
plus le test
|
| 249 |
+
`test_sprint23_anti_hallucination.py::TestTemplatesNoHardcodedLiterals`
|
| 250 |
+
échoueront sinon.
|
|
@@ -19,13 +19,19 @@ pas mais peut limiter par type.
|
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
-
from typing import Iterable
|
| 23 |
|
| 24 |
from picarones.core.narrative.facts import Fact, FactImportance, FactType
|
| 25 |
|
| 26 |
|
| 27 |
# Ordre canonique des types pour départager les ex-aequo à l'importance égale.
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
FactType.GLOBAL_LEADER_CER,
|
| 30 |
FactType.STATISTICAL_TIE,
|
| 31 |
FactType.SIGNIFICANT_GAP,
|
|
@@ -39,7 +45,10 @@ _TYPE_ORDER: tuple[FactType, ...] = (
|
|
| 39 |
FactType.COST_OUTLIER,
|
| 40 |
FactType.CONFIDENCE_WARNING,
|
| 41 |
)
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
# Paires de types qui ne sont PAS considérées comme redondantes même quand
|
|
@@ -53,11 +62,11 @@ _COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
|
|
| 53 |
})
|
| 54 |
|
| 55 |
|
| 56 |
-
def _sort_key(fact: Fact) -> tuple:
|
| 57 |
"""Clé de tri stable : importance (desc), type canonique, moteurs."""
|
| 58 |
return (
|
| 59 |
-int(fact.importance),
|
| 60 |
-
|
| 61 |
tuple(sorted(fact.engines_involved)),
|
| 62 |
fact.stratum or "",
|
| 63 |
)
|
|
@@ -106,6 +115,7 @@ def select_facts(
|
|
| 106 |
facts: Iterable[Fact],
|
| 107 |
max_facts: int = 5,
|
| 108 |
min_importance: FactImportance = FactImportance.MEDIUM,
|
|
|
|
| 109 |
) -> list[Fact]:
|
| 110 |
"""Sélectionne la synthèse finale à partir d'une liste brute de faits.
|
| 111 |
|
|
@@ -117,14 +127,24 @@ def select_facts(
|
|
| 117 |
Nombre maximal de faits retenus (défaut : 5).
|
| 118 |
min_importance:
|
| 119 |
Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
Returns
|
| 122 |
-------
|
| 123 |
Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
|
| 124 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
facts_list = [f for f in facts if int(f.importance) >= int(min_importance)]
|
| 126 |
facts_list = _remove_contradictions(facts_list)
|
| 127 |
-
ranked = sorted(facts_list, key=_sort_key)
|
| 128 |
|
| 129 |
selected: list[Fact] = []
|
| 130 |
for fact in ranked:
|
|
|
|
| 19 |
|
| 20 |
from __future__ import annotations
|
| 21 |
|
| 22 |
+
from typing import Iterable, Sequence
|
| 23 |
|
| 24 |
from picarones.core.narrative.facts import Fact, FactImportance, FactType
|
| 25 |
|
| 26 |
|
| 27 |
# Ordre canonique des types pour départager les ex-aequo à l'importance égale.
|
| 28 |
+
#
|
| 29 |
+
# Politique éditoriale (Sprint 23) — exposée et documentée :
|
| 30 |
+
# voir ``docs/developer/narrative-engine.md`` § Editorial policy.
|
| 31 |
+
# L'ordre encode quels faits sont remontés en priorité quand plusieurs ont
|
| 32 |
+
# la même ``FactImportance`` ; il peut être surchargé via le paramètre
|
| 33 |
+
# ``type_order`` de ``select_facts`` sans patcher le code.
|
| 34 |
+
DEFAULT_TYPE_ORDER: tuple[FactType, ...] = (
|
| 35 |
FactType.GLOBAL_LEADER_CER,
|
| 36 |
FactType.STATISTICAL_TIE,
|
| 37 |
FactType.SIGNIFICANT_GAP,
|
|
|
|
| 45 |
FactType.COST_OUTLIER,
|
| 46 |
FactType.CONFIDENCE_WARNING,
|
| 47 |
)
|
| 48 |
+
# Alias rétro-compatible — l'ancien nom privé reste exporté pour
|
| 49 |
+
# les tests et le code utilisateur qui s'y appuyaient.
|
| 50 |
+
_TYPE_ORDER = DEFAULT_TYPE_ORDER
|
| 51 |
+
_TYPE_INDEX: dict[FactType, int] = {t: i for i, t in enumerate(DEFAULT_TYPE_ORDER)}
|
| 52 |
|
| 53 |
|
| 54 |
# Paires de types qui ne sont PAS considérées comme redondantes même quand
|
|
|
|
| 62 |
})
|
| 63 |
|
| 64 |
|
| 65 |
+
def _sort_key(fact: Fact, type_index: dict[FactType, int]) -> tuple:
|
| 66 |
"""Clé de tri stable : importance (desc), type canonique, moteurs."""
|
| 67 |
return (
|
| 68 |
-int(fact.importance),
|
| 69 |
+
type_index.get(fact.type, len(type_index)),
|
| 70 |
tuple(sorted(fact.engines_involved)),
|
| 71 |
fact.stratum or "",
|
| 72 |
)
|
|
|
|
| 115 |
facts: Iterable[Fact],
|
| 116 |
max_facts: int = 5,
|
| 117 |
min_importance: FactImportance = FactImportance.MEDIUM,
|
| 118 |
+
type_order: Sequence[FactType] | None = None,
|
| 119 |
) -> list[Fact]:
|
| 120 |
"""Sélectionne la synthèse finale à partir d'une liste brute de faits.
|
| 121 |
|
|
|
|
| 127 |
Nombre maximal de faits retenus (défaut : 5).
|
| 128 |
min_importance:
|
| 129 |
Seuil minimal d'importance. Les faits ``LOW`` sont exclus par défaut.
|
| 130 |
+
type_order:
|
| 131 |
+
Surcharge optionnelle de l'ordre canonique des types pour départager
|
| 132 |
+
les faits d'égale importance. ``None`` (défaut) utilise
|
| 133 |
+
``DEFAULT_TYPE_ORDER``. Une institution peut passer son propre ordre
|
| 134 |
+
sans patcher le code — voir ``docs/developer/narrative-engine.md``.
|
| 135 |
|
| 136 |
Returns
|
| 137 |
-------
|
| 138 |
Liste ordonnée, prête à être rendue. Toujours ≤ ``max_facts``.
|
| 139 |
"""
|
| 140 |
+
if type_order is None:
|
| 141 |
+
type_index = _TYPE_INDEX
|
| 142 |
+
else:
|
| 143 |
+
type_index = {t: i for i, t in enumerate(type_order)}
|
| 144 |
+
|
| 145 |
facts_list = [f for f in facts if int(f.importance) >= int(min_importance)]
|
| 146 |
facts_list = _remove_contradictions(facts_list)
|
| 147 |
+
ranked = sorted(facts_list, key=lambda f: _sort_key(f, type_index))
|
| 148 |
|
| 149 |
selected: list[Fact] = []
|
| 150 |
for fact in ranked:
|
|
@@ -216,6 +216,9 @@ def detect_pareto_alternative(benchmark_data: dict) -> list[Fact]:
|
|
| 216 |
"leader_cost": round(leader_cost, 2),
|
| 217 |
"cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None,
|
| 218 |
"delta_cer_pct": round((alt_cer - leader_cer) * 100, 2),
|
|
|
|
|
|
|
|
|
|
| 219 |
},
|
| 220 |
engines_involved=(alt["engine"],),
|
| 221 |
)]
|
|
@@ -519,6 +522,7 @@ def detect_cost_outlier(benchmark_data: dict) -> list[Fact]:
|
|
| 519 |
"median_cost": round(median_cost, 2),
|
| 520 |
"ratio_to_median": round(c / median_cost, 1),
|
| 521 |
"cer_pct": round(float(p.get("cer") or 0.0) * 100, 2),
|
|
|
|
| 522 |
},
|
| 523 |
engines_involved=(p["engine"],),
|
| 524 |
))
|
|
@@ -642,6 +646,9 @@ def detect_confidence_warning(benchmark_data: dict) -> list[Fact]:
|
|
| 642 |
"mean_cer": round(float(ci.get("mean") or 0.0), 4),
|
| 643 |
"mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2),
|
| 644 |
"gap_to_runner_up_pct": round(gap * 100, 2),
|
|
|
|
|
|
|
|
|
|
| 645 |
},
|
| 646 |
engines_involved=(engine_name,),
|
| 647 |
))
|
|
|
|
| 216 |
"leader_cost": round(leader_cost, 2),
|
| 217 |
"cost_saving_ratio": round(leader_cost / alt_cost, 1) if alt_cost > 0 else None,
|
| 218 |
"delta_cer_pct": round((alt_cer - leader_cer) * 100, 2),
|
| 219 |
+
# Unité du coût — propagée pour traçabilité (le template ne
|
| 220 |
+
# hardcode plus "1000 pages").
|
| 221 |
+
"cost_unit_pages": 1000,
|
| 222 |
},
|
| 223 |
engines_involved=(alt["engine"],),
|
| 224 |
)]
|
|
|
|
| 522 |
"median_cost": round(median_cost, 2),
|
| 523 |
"ratio_to_median": round(c / median_cost, 1),
|
| 524 |
"cer_pct": round(float(p.get("cer") or 0.0) * 100, 2),
|
| 525 |
+
"cost_unit_pages": 1000,
|
| 526 |
},
|
| 527 |
engines_involved=(p["engine"],),
|
| 528 |
))
|
|
|
|
| 646 |
"mean_cer": round(float(ci.get("mean") or 0.0), 4),
|
| 647 |
"mean_cer_pct": round(float(ci.get("mean") or 0.0) * 100, 2),
|
| 648 |
"gap_to_runner_up_pct": round(gap * 100, 2),
|
| 649 |
+
# Niveau de confiance des bornes — propagé pour traçabilité
|
| 650 |
+
# anti-hallucination (le template ne hardcode plus "95 %").
|
| 651 |
+
"confidence_level": 95,
|
| 652 |
},
|
| 653 |
engines_involved=(engine_name,),
|
| 654 |
))
|
|
@@ -42,14 +42,14 @@ speed_winner: >-
|
|
| 42 |
median) for comparable quality (CER {cer_pct} %).
|
| 43 |
|
| 44 |
confidence_warning: >-
|
| 45 |
-
Ranking is fragile: the
|
| 46 |
{ci_width_pct} CER points, compared with a gap of {gap_to_runner_up_pct} points to the runner-up.
|
| 47 |
|
| 48 |
pareto_alternative: >-
|
| 49 |
At much lower cost, {engine} offers an interesting trade-off ({cer_pct} %
|
| 50 |
-
CER for {cost} €/
|
| 51 |
{leader}, i.e. ×{cost_saving_ratio} cheaper).
|
| 52 |
|
| 53 |
cost_outlier: >-
|
| 54 |
-
Disproportionate cost for {engine} ({cost} €/
|
| 55 |
the median) without a compensating quality advantage (CER {cer_pct} %).
|
|
|
|
| 42 |
median) for comparable quality (CER {cer_pct} %).
|
| 43 |
|
| 44 |
confidence_warning: >-
|
| 45 |
+
Ranking is fragile: the {confidence_level} % confidence interval of {engine} spans
|
| 46 |
{ci_width_pct} CER points, compared with a gap of {gap_to_runner_up_pct} points to the runner-up.
|
| 47 |
|
| 48 |
pareto_alternative: >-
|
| 49 |
At much lower cost, {engine} offers an interesting trade-off ({cer_pct} %
|
| 50 |
+
CER for {cost} €/{cost_unit_pages} pages, vs {leader_cer_pct} % / {leader_cost} € for
|
| 51 |
{leader}, i.e. ×{cost_saving_ratio} cheaper).
|
| 52 |
|
| 53 |
cost_outlier: >-
|
| 54 |
+
Disproportionate cost for {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median}
|
| 55 |
the median) without a compensating quality advantage (CER {cer_pct} %).
|
|
@@ -46,14 +46,14 @@ speed_winner: >-
|
|
| 46 |
que la médiane) pour un CER comparable ({cer_pct} %).
|
| 47 |
|
| 48 |
confidence_warning: >-
|
| 49 |
-
Classement fragile : l'intervalle de confiance à
|
| 50 |
sur {ci_width_pct} points de CER, à comparer à l'écart de {gap_to_runner_up_pct} points avec le second.
|
| 51 |
|
| 52 |
pareto_alternative: >-
|
| 53 |
À coût sensiblement inférieur, {engine} offre un compromis intéressant
|
| 54 |
-
({cer_pct} % de CER pour {cost} €/
|
| 55 |
{leader_cost} € pour {leader}, soit ×{cost_saving_ratio} moins cher).
|
| 56 |
|
| 57 |
cost_outlier: >-
|
| 58 |
-
Coût disproportionné pour {engine} ({cost} €/
|
| 59 |
la médiane) sans avantage de qualité compensatoire (CER {cer_pct} %).
|
|
|
|
| 46 |
que la médiane) pour un CER comparable ({cer_pct} %).
|
| 47 |
|
| 48 |
confidence_warning: >-
|
| 49 |
+
Classement fragile : l'intervalle de confiance à {confidence_level} % de {engine} s'étend
|
| 50 |
sur {ci_width_pct} points de CER, à comparer à l'écart de {gap_to_runner_up_pct} points avec le second.
|
| 51 |
|
| 52 |
pareto_alternative: >-
|
| 53 |
À coût sensiblement inférieur, {engine} offre un compromis intéressant
|
| 54 |
+
({cer_pct} % de CER pour {cost} €/{cost_unit_pages} pages, contre {leader_cer_pct} % /
|
| 55 |
{leader_cost} € pour {leader}, soit ×{cost_saving_ratio} moins cher).
|
| 56 |
|
| 57 |
cost_outlier: >-
|
| 58 |
+
Coût disproportionné pour {engine} ({cost} €/{cost_unit_pages} pages, ×{ratio_to_median}
|
| 59 |
la médiane) sans avantage de qualité compensatoire (CER {cer_pct} %).
|
|
@@ -493,10 +493,13 @@ def _numbers_in_payload(payload: dict) -> set[str]:
|
|
| 493 |
return out
|
| 494 |
|
| 495 |
|
| 496 |
-
#
|
| 497 |
-
#
|
| 498 |
-
#
|
| 499 |
-
|
|
|
|
|
|
|
|
|
|
| 500 |
|
| 501 |
|
| 502 |
class TestAntiHallucinationTraceability:
|
|
|
|
| 493 |
return out
|
| 494 |
|
| 495 |
|
| 496 |
+
# Sprint 23 : whitelist vidée. Tout nombre rendu dans la synthèse doit
|
| 497 |
+
# venir du payload d'un Fact. Le seuil de confiance (95) est désormais
|
| 498 |
+
# propagé via ``confidence_level`` dans le payload de
|
| 499 |
+
# ``FactType.CONFIDENCE_WARNING`` et l'unité du coût (1000 pages) via
|
| 500 |
+
# ``cost_unit_pages`` dans ``PARETO_ALTERNATIVE`` / ``COST_OUTLIER``.
|
| 501 |
+
# Aucun littéral hors-payload n'est plus autorisé.
|
| 502 |
+
_TEMPLATE_CONSTANTS: frozenset[str] = frozenset()
|
| 503 |
|
| 504 |
|
| 505 |
class TestAntiHallucinationTraceability:
|
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 23 — intégrité anti-hallucination du moteur narratif.
|
| 2 |
+
|
| 3 |
+
Le Sprint 23 ferme le trou méthodologique laissé par le Sprint 19 : le test
|
| 4 |
+
de traçabilité des nombres dans la synthèse rendue tolérait une whitelist
|
| 5 |
+
``{"95", "100"}`` de littéraux non-traçables au payload. Cette whitelist
|
| 6 |
+
est désormais vide ; toute valeur numérique apparaissant dans la synthèse
|
| 7 |
+
doit provenir du ``Fact.payload`` d'un détecteur.
|
| 8 |
+
|
| 9 |
+
Ce module vérifie quatre choses :
|
| 10 |
+
|
| 11 |
+
1. Les payloads des détecteurs concernés (``CONFIDENCE_WARNING``,
|
| 12 |
+
``PARETO_ALTERNATIVE``, ``COST_OUTLIER``) exposent bien les nouveaux
|
| 13 |
+
champs (``confidence_level``, ``cost_unit_pages``).
|
| 14 |
+
2. Les templates FR/EN ne contiennent plus les littéraux ``95`` ni ``1000``
|
| 15 |
+
en dehors d'un placeholder ``{...}``.
|
| 16 |
+
3. Le test de traçabilité reste vert avec une whitelist vide.
|
| 17 |
+
4. La stabilité du bootstrap est testée : deux seeds produisent des bornes
|
| 18 |
+
d'IC à ±0,5 pp pour ``n=20`` documents — garantit que l'IC affiché
|
| 19 |
+
dans le rapport est représentatif (sinon il faudrait passer
|
| 20 |
+
``n_iter=5000``).
|
| 21 |
+
5. Le pipeline narratif EN bout-en-bout produit des phrases anglaises
|
| 22 |
+
bien formées (pas de placeholder non substitué) sur fixtures réalistes.
|
| 23 |
+
6. ``select_facts`` accepte un ``type_order`` custom et le respecte.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import re
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import pytest
|
| 32 |
+
|
| 33 |
+
from picarones.core.narrative import (
|
| 34 |
+
Fact,
|
| 35 |
+
FactImportance,
|
| 36 |
+
FactType,
|
| 37 |
+
build_synthesis,
|
| 38 |
+
select_facts,
|
| 39 |
+
)
|
| 40 |
+
from picarones.core.narrative.arbiter import DEFAULT_TYPE_ORDER
|
| 41 |
+
from picarones.core.statistics import bootstrap_ci
|
| 42 |
+
|
| 43 |
+
ROOT = Path(__file__).parent.parent
|
| 44 |
+
TEMPLATES_DIR = ROOT / "picarones" / "core" / "narrative" / "templates"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
# Fixtures locales — minimum viable pour faire émettre chaque détecteur
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
|
| 51 |
+
def _full_data() -> dict:
|
| 52 |
+
"""Données qui déclenchent ``CONFIDENCE_WARNING`` (IC large) et le Pareto."""
|
| 53 |
+
return {
|
| 54 |
+
"ranking": [
|
| 55 |
+
{"engine": "A", "mean_cer": 0.05, "wer": 0.10},
|
| 56 |
+
{"engine": "B", "mean_cer": 0.06, "wer": 0.12},
|
| 57 |
+
{"engine": "C", "mean_cer": 0.20, "wer": 0.30},
|
| 58 |
+
],
|
| 59 |
+
"n_documents": 20,
|
| 60 |
+
"statistics": {
|
| 61 |
+
"bootstrap_cis": [
|
| 62 |
+
# IC large pour A → confidence_warning
|
| 63 |
+
{"engine": "A", "mean": 0.05, "ci_lower": 0.01, "ci_upper": 0.15},
|
| 64 |
+
{"engine": "B", "mean": 0.06, "ci_lower": 0.05, "ci_upper": 0.07},
|
| 65 |
+
{"engine": "C", "mean": 0.20, "ci_lower": 0.18, "ci_upper": 0.22},
|
| 66 |
+
],
|
| 67 |
+
},
|
| 68 |
+
"pareto": {
|
| 69 |
+
"cost": {
|
| 70 |
+
"front": ["A", "B"],
|
| 71 |
+
"points": [
|
| 72 |
+
{"engine": "A", "cer": 0.05, "cost": 50.0},
|
| 73 |
+
{"engine": "B", "cer": 0.06, "cost": 5.0}, # alternative pas chère
|
| 74 |
+
{"engine": "C", "cer": 0.20, "cost": 300.0}, # cost outlier
|
| 75 |
+
],
|
| 76 |
+
},
|
| 77 |
+
},
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ---------------------------------------------------------------------------
|
| 82 |
+
# 1. Payloads exposent les nouveaux champs
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
|
| 85 |
+
class TestPayloadsCarryFormerlyHardcodedConstants:
|
| 86 |
+
def test_confidence_warning_payload_carries_confidence_level(self):
|
| 87 |
+
from picarones.core.narrative.detectors import detect_confidence_warning
|
| 88 |
+
|
| 89 |
+
facts = detect_confidence_warning(_full_data())
|
| 90 |
+
assert facts, "fixture devrait déclencher au moins un confidence_warning"
|
| 91 |
+
for f in facts:
|
| 92 |
+
assert f.payload.get("confidence_level") == 95, (
|
| 93 |
+
"Le seuil 95 doit être propagé dans le payload "
|
| 94 |
+
"(plus de littéral hardcodé dans le template)."
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def test_pareto_alternative_payload_carries_cost_unit(self):
|
| 98 |
+
from picarones.core.narrative.detectors import detect_pareto_alternative
|
| 99 |
+
|
| 100 |
+
facts = detect_pareto_alternative(_full_data())
|
| 101 |
+
assert facts, "fixture devrait déclencher au moins un pareto_alternative"
|
| 102 |
+
for f in facts:
|
| 103 |
+
assert f.payload.get("cost_unit_pages") == 1000
|
| 104 |
+
|
| 105 |
+
def test_cost_outlier_payload_carries_cost_unit(self):
|
| 106 |
+
from picarones.core.narrative.detectors import detect_cost_outlier
|
| 107 |
+
|
| 108 |
+
facts = detect_cost_outlier(_full_data())
|
| 109 |
+
assert facts, "fixture devrait déclencher au moins un cost_outlier"
|
| 110 |
+
for f in facts:
|
| 111 |
+
assert f.payload.get("cost_unit_pages") == 1000
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ---------------------------------------------------------------------------
|
| 115 |
+
# 2. Les templates ne hardcodent plus les littéraux 95 et 1000
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
|
| 118 |
+
# Toute occurrence d'un nombre HORS d'un placeholder ``{...}`` est
|
| 119 |
+
# considérée comme un littéral hardcodé. On scanne en remplaçant d'abord
|
| 120 |
+
# tous les placeholders par un marqueur neutre.
|
| 121 |
+
_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
|
| 122 |
+
_NUMBER_RE = re.compile(r"\b\d+\b")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _strip_placeholders(template: str) -> str:
|
| 126 |
+
return _PLACEHOLDER_RE.sub("PLACEHOLDER", template)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@pytest.mark.parametrize("lang", ["fr", "en"])
|
| 130 |
+
class TestTemplatesNoHardcodedLiterals:
|
| 131 |
+
def test_no_hardcoded_95(self, lang):
|
| 132 |
+
import yaml
|
| 133 |
+
|
| 134 |
+
path = TEMPLATES_DIR / f"{lang}.yaml"
|
| 135 |
+
templates = yaml.safe_load(path.read_text(encoding="utf-8"))
|
| 136 |
+
for key, tpl in templates.items():
|
| 137 |
+
stripped = _strip_placeholders(tpl)
|
| 138 |
+
numbers = _NUMBER_RE.findall(stripped)
|
| 139 |
+
assert "95" not in numbers, (
|
| 140 |
+
f"Template {lang}/{key} contient encore le littéral 95 ; "
|
| 141 |
+
"doit utiliser {confidence_level}."
|
| 142 |
+
)
|
| 143 |
+
assert "1000" not in numbers, (
|
| 144 |
+
f"Template {lang}/{key} contient encore le littéral 1000 ; "
|
| 145 |
+
"doit utiliser {cost_unit_pages}."
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
# 3. Pipeline complet produit une synthèse traçable, whitelist vide
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
class TestEndToEndWithEmptyWhitelist:
|
| 154 |
+
@pytest.mark.parametrize("lang", ["fr", "en"])
|
| 155 |
+
def test_synthesis_renders_without_unsubstituted_placeholders(self, lang):
|
| 156 |
+
result = build_synthesis(_full_data(), lang)
|
| 157 |
+
for sentence in result["sentences"]:
|
| 158 |
+
assert "{" not in sentence and "}" not in sentence, (
|
| 159 |
+
f"Placeholder non substitué dans la synthèse {lang} : {sentence!r}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
@pytest.mark.parametrize("lang", ["fr", "en"])
|
| 163 |
+
def test_every_number_traceable_with_empty_whitelist(self, lang):
|
| 164 |
+
from picarones.core.narrative import extract_numbers
|
| 165 |
+
|
| 166 |
+
from tests.test_sprint19_narrative_engine import _numbers_in_payload
|
| 167 |
+
|
| 168 |
+
result = build_synthesis(_full_data(), lang)
|
| 169 |
+
allowed: set[str] = set()
|
| 170 |
+
for f in result["facts"]:
|
| 171 |
+
allowed |= _numbers_in_payload(f.get("payload", {}))
|
| 172 |
+
|
| 173 |
+
unknown: list[tuple[str, str]] = []
|
| 174 |
+
for sentence in result["sentences"]:
|
| 175 |
+
for num in extract_numbers(sentence):
|
| 176 |
+
num_norm = num.replace(",", ".")
|
| 177 |
+
if num_norm not in allowed:
|
| 178 |
+
unknown.append((num, sentence))
|
| 179 |
+
assert not unknown, (
|
| 180 |
+
f"[{lang}] Nombres non traçables au payload : {unknown}"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# ---------------------------------------------------------------------------
|
| 185 |
+
# 4. Stabilité du bootstrap entre seeds
|
| 186 |
+
# ---------------------------------------------------------------------------
|
| 187 |
+
|
| 188 |
+
class TestBootstrapStabilityAcrossSeeds:
|
| 189 |
+
"""Vérifie que ``bootstrap_ci`` à n_iter=1000 est suffisamment stable.
|
| 190 |
+
|
| 191 |
+
Pour 20 documents avec un CER moyen ~5 %, l'écart entre deux seeds sur
|
| 192 |
+
chacune des bornes (lower, upper) doit rester inférieur à 0,5 point de
|
| 193 |
+
pourcentage de CER (= 0.005 en absolu). Si ce test échoue à l'avenir,
|
| 194 |
+
cela signifie qu'il faut passer à ``n_iter=5000`` pour fiabiliser
|
| 195 |
+
l'IC affiché dans le rapport.
|
| 196 |
+
"""
|
| 197 |
+
|
| 198 |
+
def test_bootstrap_stable_for_typical_cer_distribution(self):
|
| 199 |
+
# 20 valeurs de CER autour de 5 % — distribution réaliste.
|
| 200 |
+
values = [
|
| 201 |
+
0.02, 0.03, 0.04, 0.04, 0.045, 0.05, 0.05, 0.05, 0.055, 0.055,
|
| 202 |
+
0.06, 0.06, 0.06, 0.065, 0.07, 0.07, 0.075, 0.08, 0.085, 0.10,
|
| 203 |
+
]
|
| 204 |
+
lo1, hi1 = bootstrap_ci(values, n_iter=1000, seed=42)
|
| 205 |
+
lo2, hi2 = bootstrap_ci(values, n_iter=1000, seed=7)
|
| 206 |
+
assert abs(lo1 - lo2) < 0.005, (
|
| 207 |
+
f"Borne basse instable entre seeds (Δ = {abs(lo1 - lo2):.4f}) ; "
|
| 208 |
+
"envisager n_iter=5000."
|
| 209 |
+
)
|
| 210 |
+
assert abs(hi1 - hi2) < 0.005, (
|
| 211 |
+
f"Borne haute instable entre seeds (Δ = {abs(hi1 - hi2):.4f}) ; "
|
| 212 |
+
"envisager n_iter=5000."
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
def test_bootstrap_strictly_deterministic_same_seed(self):
|
| 216 |
+
values = [0.01, 0.05, 0.1, 0.2]
|
| 217 |
+
a = bootstrap_ci(values, n_iter=1000, seed=42)
|
| 218 |
+
b = bootstrap_ci(values, n_iter=1000, seed=42)
|
| 219 |
+
assert a == b, "Bootstrap doit être bit-à-bit reproductible sur seed identique."
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ---------------------------------------------------------------------------
|
| 223 |
+
# 5. select_facts respecte un type_order custom
|
| 224 |
+
# ---------------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
class TestSelectFactsCustomTypeOrder:
|
| 227 |
+
def _make_facts(self) -> list[Fact]:
|
| 228 |
+
return [
|
| 229 |
+
Fact(
|
| 230 |
+
type=FactType.GLOBAL_LEADER_CER,
|
| 231 |
+
importance=FactImportance.HIGH,
|
| 232 |
+
payload={"engine": "A"},
|
| 233 |
+
engines_involved=("A",),
|
| 234 |
+
),
|
| 235 |
+
Fact(
|
| 236 |
+
type=FactType.SPEED_WINNER,
|
| 237 |
+
importance=FactImportance.HIGH,
|
| 238 |
+
payload={"engine": "B"},
|
| 239 |
+
engines_involved=("B",),
|
| 240 |
+
),
|
| 241 |
+
Fact(
|
| 242 |
+
type=FactType.PARETO_ALTERNATIVE,
|
| 243 |
+
importance=FactImportance.HIGH,
|
| 244 |
+
payload={"engine": "C"},
|
| 245 |
+
engines_involved=("C",),
|
| 246 |
+
),
|
| 247 |
+
]
|
| 248 |
+
|
| 249 |
+
def test_default_order_puts_global_leader_first(self):
|
| 250 |
+
selected = select_facts(self._make_facts(), max_facts=3)
|
| 251 |
+
assert selected[0].type == FactType.GLOBAL_LEADER_CER
|
| 252 |
+
|
| 253 |
+
def test_custom_order_promotes_speed_winner(self):
|
| 254 |
+
custom = (
|
| 255 |
+
FactType.SPEED_WINNER,
|
| 256 |
+
FactType.GLOBAL_LEADER_CER,
|
| 257 |
+
FactType.PARETO_ALTERNATIVE,
|
| 258 |
+
) + tuple(t for t in DEFAULT_TYPE_ORDER if t not in {
|
| 259 |
+
FactType.SPEED_WINNER,
|
| 260 |
+
FactType.GLOBAL_LEADER_CER,
|
| 261 |
+
FactType.PARETO_ALTERNATIVE,
|
| 262 |
+
})
|
| 263 |
+
selected = select_facts(self._make_facts(), max_facts=3, type_order=custom)
|
| 264 |
+
assert selected[0].type == FactType.SPEED_WINNER, (
|
| 265 |
+
"Avec un type_order custom plaçant SPEED_WINNER en premier, "
|
| 266 |
+
"il doit ressortir avant GLOBAL_LEADER_CER à importance égale."
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
def test_unknown_types_in_custom_order_fall_to_end(self):
|
| 270 |
+
# Un type_order réduit (ne mentionne que GLOBAL_LEADER_CER) ; les autres
|
| 271 |
+
# types sont relégués à la fin sans crash.
|
| 272 |
+
custom = (FactType.GLOBAL_LEADER_CER,)
|
| 273 |
+
selected = select_facts(self._make_facts(), max_facts=3, type_order=custom)
|
| 274 |
+
assert selected[0].type == FactType.GLOBAL_LEADER_CER
|
| 275 |
+
assert len(selected) == 3
|