Spaces:
Running
sprint46: A.III stratification — vue HTML + détecteur narratif (clôture A.III)
Browse filesSuite directe du Sprint 45 (couche backend). La vue stratifiée est
désormais rendue dans le rapport et un détecteur signale
automatiquement les corpus hétérogènes.
Nouveau picarones/report/stratification_render.py
- build_stratified_ranking_html : un <details> natif (collapsible
sans JS) par strate avec tableau moteur × (médiane, moyenne, docs).
Cellule médiane colorée par gradient vert (faible CER) → rouge
(élevé). Premier <details> ouvert par défaut. Bandeau
d'avertissement en tête si corpus_homogeneity fourni.
- Rendu strictement server-side, pas de JavaScript, déterministe.
- Anti-injection : noms de moteurs et de strates passés à html.escape.
Câblage rapport
- _build_report_data expose available_strata, stratified_ranking,
corpus_homogeneity au top-level.
- ReportGenerator.generate calcule le bloc HTML et le passe au
template view_ranking.html qui l'insère après le tableau principal
uniquement si stratification disponible.
Détecteur narratif
- Nouveau FactType.STRATIFICATION_RECOMMENDED (priority 45 — entre
STRATUM_WINNER 40 et STRATUM_COLLAPSE 50).
- detect_stratification_recommended lit corpus_homogeneity et émet
un Fact quand le gap inter-strate du leader dépasse 5 points
(HIGH au-delà de 10 points, MEDIUM sinon).
- Templates FR/EN sans nombres en dur (vérifié).
- L'arbitre marque {GLOBAL_LEADER_CER, STRATIFICATION_RECOMMENDED}
comme paire complémentaire (les deux phrases peuvent cohabiter).
- _FALLBACK_TYPE_ORDER mis à jour pour insérer STRATIFICATION_RECOMMENDED
à sa position canonique (après STRATUM_WINNER).
i18n : +8 clés FR/EN pour la vue stratifiée
(stratification_caption, stratification_description, *_label,
stratification_gap_summary).
Tests : +38 dans test_sprint46_stratification_html.py couvrant le
rendu (un <details> par strate, métriques, premier ouvert), le
bandeau d'hétérogénéité, le masquage adaptatif (4 cas),
l'anti-injection (engine et stratum avec balises HTML), les seuils
du détecteur (4 cas), la traçabilité anti-hallucination FR + EN,
l'absence de chiffres en dur dans les templates, l'intégration
ReportGenerator FR + EN, et la complétude i18n.
Suite complète : 1826 → 1864 passed, 2 skipped, 0 failed.
A.III (stratification) clôturée bout-en-bout : couche backend
(Sprint 45) + vue HTML + détecteur narratif (Sprint 46).
- CHANGELOG.md +49 -9
- CLAUDE.md +2 -1
- picarones/core/narrative/arbiter.py +8 -0
- picarones/core/narrative/detectors.py +64 -0
- picarones/core/narrative/facts.py +6 -0
- picarones/core/narrative/templates/en.yaml +8 -0
- picarones/core/narrative/templates/fr.yaml +8 -0
- picarones/report/generator.py +17 -0
- picarones/report/i18n/en.json +8 -0
- picarones/report/i18n/fr.json +8 -0
- picarones/report/stratification_render.py +202 -0
- picarones/report/templates/view_ranking.html +6 -0
- tests/test_sprint46_stratification_html.py +366 -0
|
@@ -16,6 +16,47 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 45 — A.III stratification par `script_type` : couche
|
| 20 |
d'agrégation backend.** Première brique de la « plus haute valeur
|
| 21 |
ajoutée transversale » du plan d'évolution. Le rapport peut
|
|
@@ -438,17 +479,16 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 438 |
|
| 439 |
### Tests
|
| 440 |
|
| 441 |
-
- 1478 →
|
| 442 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 443 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 444 |
-
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45
|
| 445 |
-
**Phase 0 close ; Étape 2 du plan d'évolution :
|
| 446 |
-
(A.II.1.c), NER (A.II.1.a)
|
| 447 |
-
bout-en-bout calcul → runner →
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
adapter).**
|
| 452 |
|
| 453 |
---
|
| 454 |
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 46 — A.III stratification par `script_type` : vue HTML +
|
| 20 |
+
détecteur narratif (clôture A.III)**. Suite directe du Sprint 45
|
| 21 |
+
(couche backend). La vue stratifiée est désormais rendue dans le
|
| 22 |
+
rapport et un détecteur signale automatiquement les corpus
|
| 23 |
+
hétérogènes.
|
| 24 |
+
- Nouveau module `picarones/report/stratification_render.py` :
|
| 25 |
+
`build_stratified_ranking_html` rend un `<details>` natif
|
| 26 |
+
(collapsible sans JS) par strate avec tableau moteur × (médiane,
|
| 27 |
+
moyenne, docs). Cellule médiane colorée par gradient vert (faible
|
| 28 |
+
CER) → rouge (élevé). Premier `<details>` ouvert par défaut pour
|
| 29 |
+
donner le contexte. Bandeau d'avertissement en tête si
|
| 30 |
+
`corpus_homogeneity` fourni (écart inter-strate du leader).
|
| 31 |
+
- `_build_report_data` expose `available_strata`,
|
| 32 |
+
`stratified_ranking`, `corpus_homogeneity` au top-level. Le bloc
|
| 33 |
+
HTML est passé au template `view_ranking.html` qui l'insère après
|
| 34 |
+
le tableau principal **uniquement si stratification disponible**
|
| 35 |
+
(rapport adaptatif).
|
| 36 |
+
- Nouveau `FactType.STRATIFICATION_RECOMMENDED` (priority 45,
|
| 37 |
+
importance MEDIUM ou HIGH selon le gap) avec détecteur
|
| 38 |
+
`detect_stratification_recommended` qui lit `corpus_homogeneity`
|
| 39 |
+
et émet un Fact quand le gap inter-strate du leader dépasse
|
| 40 |
+
5 points de CER (HIGH au-delà de 10 points). Templates FR/EN
|
| 41 |
+
sans nombres en dur.
|
| 42 |
+
- L'arbitre marque la paire `{GLOBAL_LEADER_CER,
|
| 43 |
+
STRATIFICATION_RECOMMENDED}` comme **complémentaire** : la
|
| 44 |
+
recommandation peut cohabiter avec la phrase du leader pour
|
| 45 |
+
nuancer.
|
| 46 |
+
- +8 clés i18n FR/EN pour la vue stratifiée
|
| 47 |
+
(`stratification_caption`, `stratification_description`,
|
| 48 |
+
`stratification_*_label`, `stratification_gap_summary`).
|
| 49 |
+
- Anti-injection HTML via `html.escape` sur les noms de moteurs et
|
| 50 |
+
les noms de strates.
|
| 51 |
+
- +38 tests dans `test_sprint46_stratification_html.py` couvrant
|
| 52 |
+
le rendu (un `<details>` par strate, métriques visibles, premier
|
| 53 |
+
ouvert), le bandeau d'hétérogénéité, le masquage adaptatif (4
|
| 54 |
+
cas), l'anti-injection (engine et stratum avec balises HTML),
|
| 55 |
+
les seuils du détecteur (4 cas), la traçabilité
|
| 56 |
+
anti-hallucination FR + EN, l'absence de chiffres en dur dans
|
| 57 |
+
les templates, l'intégration `ReportGenerator` FR + EN, et la
|
| 58 |
+
complétude i18n.
|
| 59 |
+
|
| 60 |
- **Sprint 45 — A.III stratification par `script_type` : couche
|
| 61 |
d'agrégation backend.** Première brique de la « plus haute valeur
|
| 62 |
ajoutée transversale » du plan d'évolution. Le rapport peut
|
|
|
|
| 479 |
|
| 480 |
### Tests
|
| 481 |
|
| 482 |
+
- 1478 → 1864 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
|
| 483 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 484 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 485 |
+
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46).
|
| 486 |
+
Aucune régression. **Phase 0 close ; Étape 2 du plan d'évolution :
|
| 487 |
+
inter-moteurs (A.II.1.c), NER (A.II.1.a), calibration (A.II.1.b)
|
| 488 |
+
et stratification (A.III) livrés bout-en-bout calcul → runner →
|
| 489 |
+
HTML ; A.I.2 médiane par défaut livré (Sprint 44). Reste
|
| 490 |
+
l'adaptation effective des engines pour exposer leurs confidences
|
| 491 |
+
natives (un sprint par adapter).**
|
|
|
|
| 492 |
|
| 493 |
---
|
| 494 |
|
|
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
|
|
|
| 210 |
| 45 | **Sprint 14 du plan d'évolution 2026 — Étape 2 / axe A.III : stratification par `script_type` (couche backend)**. Première brique de la « plus haute valeur ajoutée transversale » du plan. `BenchmarkResult.doc_strata: Optional[dict[str, str]]` ajouté (map `{doc_id: script_type}` capturée par le runner avant `compact()` qui efface `image_quality`). Trois nouvelles méthodes : `available_strata()` (liste triée des strates distinctes, ignore les vides) ; `stratified_ranking()` qui retourne `{stratum: [ranking_entry]}` avec mean/median CER recalculés par strate, tri par médiane (Sprint 44), inclut les moteurs absents d'une strate sous forme d'entrée dégénérée (mean/median = None) ; `corpus_homogeneity()` qui pour le moteur leader global retourne l'écart inter-strate de la médiane CER et la paire min/max — base du futur avertissement « ce corpus est hétérogène ». `as_dict()` expose les nouveaux champs quand renseignés (rétrocompat stricte sinon). +16 tests dans `test_sprint45_stratification.py` couvrant champ, available_strata, stratified_ranking (1 entrée/moteur/strate, métriques per-strate, tri par médiane, moteurs absents), corpus_homogeneity, sérialisation, et un **test propriété réaliste** : le leader global peut perdre sur une strate (Tesseract domine globalement mais Pero gagne sur le manuscrit). **Verrou levé** : la couche d'agrégation par strate est en place ; la vue HTML stratifiée + toggle UI viendront dans un sprint dédié, et un détecteur narratif `STRATIFICATION_RECOMMENDED` peut maintenant lire `corpus_homogeneity()` pour suggérer la vue stratifiée. |
|
| 211 |
| 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
|
| 212 |
| 43 | **Sprint 12 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : vue HTML calibration (clôture A.II.1.b côté rapport)**. Nouveau module `picarones/report/calibration_render.py` : `build_calibration_summary_html` rend un tableau résumé (ECE, MCE, accuracy moyenne, confidence moyenne, n_predictions, doc_count) avec cellule ECE colorée par gradient vert (bien calibré) → rouge (mal calibré) ; `build_reliability_diagram_svg` rend un SVG par moteur avec barres d'accuracy par bin, ligne reliant les points `(avg_confidence, accuracy)`, diagonale en pointillé pour la calibration parfaite, axes annotés (graduations 0/0.5/1) ; `build_reliability_diagrams_grid_html` génère une grille auto-fit (un SVG par moteur ayant `aggregated_calibration`). Rendu strictement server-side, pas de JS, déterministe. `_build_report_data` expose `aggregated_calibration` par moteur ; `ReportGenerator.generate` calcule les blocs et les passe à `view_analyses.html` qui les affiche **uniquement si ≥ 1 moteur a un `aggregated_calibration`** (rapport adaptatif). Anti-injection HTML via `html.escape`. +13 clés i18n FR/EN. +43 tests dans `test_sprint43_calibration_html.py` couvrant le rendu (résumé, SVG, grille), le masquage adaptatif, l'anti-injection, l'intégration FR + EN, la complétude i18n. **Verrou levé** : A.II.1.b (calibration) est désormais visible bout-en-bout dans le rapport — il manque uniquement l'adaptation effective des engines pour exposer leurs confidences natives (un sprint par adapter : Tesseract `image_to_data`, Pero `PageLayout`, Mistral `confidence`, Google Vision `Word.confidence`, Azure DI). |
|
|
@@ -263,7 +264,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 263 |
## Contexte développement
|
| 264 |
|
| 265 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 266 |
-
- **Tests** :
|
| 267 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 268 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 269 |
- **Transcript de la conversation de développement** :
|
|
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
| 210 |
+
| 46 | **Sprint 15 du plan d'évolution 2026 — Étape 2 / axe A.III : vue HTML stratifiée + détecteur narratif (clôture A.III)**. Suite directe du Sprint 45 (couche backend). Nouveau module `picarones/report/stratification_render.py` : `build_stratified_ranking_html` rend un `<details>` natif (collapsible sans JS) par strate avec tableau moteur × (médiane, moyenne, docs), cellule médiane colorée par gradient vert→rouge, premier `<details>` ouvert par défaut, bandeau d'avertissement en tête si `corpus_homogeneity` fourni. `_build_report_data` expose `available_strata`/`stratified_ranking`/`corpus_homogeneity` au top-level ; `view_ranking.html` insère le bloc après le tableau principal **uniquement si stratification disponible**. Nouveau `FactType.STRATIFICATION_RECOMMENDED` (priority 45, importance MEDIUM ou HIGH selon le gap) + détecteur `detect_stratification_recommended` (seuil 5 points / 10 points de CER inter-strate). Templates FR/EN sans nombres en dur. L'arbitre marque la paire `{GLOBAL_LEADER_CER, STRATIFICATION_RECOMMENDED}` comme complémentaire. +8 clés i18n FR/EN. Anti-injection HTML via `html.escape`. +38 tests dans `test_sprint46_stratification_html.py`. **Verrou levé** : A.III (stratification) est désormais livré bout-en-bout — couche backend (Sprint 45) + vue HTML + détecteur narratif (Sprint 46) ; le lecteur du rapport voit immédiatement quand le corpus est hétérogène et est invité à consulter la vue stratifiée. |
|
| 211 |
| 45 | **Sprint 14 du plan d'évolution 2026 — Étape 2 / axe A.III : stratification par `script_type` (couche backend)**. Première brique de la « plus haute valeur ajoutée transversale » du plan. `BenchmarkResult.doc_strata: Optional[dict[str, str]]` ajouté (map `{doc_id: script_type}` capturée par le runner avant `compact()` qui efface `image_quality`). Trois nouvelles méthodes : `available_strata()` (liste triée des strates distinctes, ignore les vides) ; `stratified_ranking()` qui retourne `{stratum: [ranking_entry]}` avec mean/median CER recalculés par strate, tri par médiane (Sprint 44), inclut les moteurs absents d'une strate sous forme d'entrée dégénérée (mean/median = None) ; `corpus_homogeneity()` qui pour le moteur leader global retourne l'écart inter-strate de la médiane CER et la paire min/max — base du futur avertissement « ce corpus est hétérogène ». `as_dict()` expose les nouveaux champs quand renseignés (rétrocompat stricte sinon). +16 tests dans `test_sprint45_stratification.py` couvrant champ, available_strata, stratified_ranking (1 entrée/moteur/strate, métriques per-strate, tri par médiane, moteurs absents), corpus_homogeneity, sérialisation, et un **test propriété réaliste** : le leader global peut perdre sur une strate (Tesseract domine globalement mais Pero gagne sur le manuscrit). **Verrou levé** : la couche d'agrégation par strate est en place ; la vue HTML stratifiée + toggle UI viendront dans un sprint dédié, et un détecteur narratif `STRATIFICATION_RECOMMENDED` peut maintenant lire `corpus_homogeneity()` pour suggérer la vue stratifiée. |
|
| 212 |
| 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
|
| 213 |
| 43 | **Sprint 12 du plan d'évolution 2026 — Étape 2 / axe A.II.1.b : vue HTML calibration (clôture A.II.1.b côté rapport)**. Nouveau module `picarones/report/calibration_render.py` : `build_calibration_summary_html` rend un tableau résumé (ECE, MCE, accuracy moyenne, confidence moyenne, n_predictions, doc_count) avec cellule ECE colorée par gradient vert (bien calibré) → rouge (mal calibré) ; `build_reliability_diagram_svg` rend un SVG par moteur avec barres d'accuracy par bin, ligne reliant les points `(avg_confidence, accuracy)`, diagonale en pointillé pour la calibration parfaite, axes annotés (graduations 0/0.5/1) ; `build_reliability_diagrams_grid_html` génère une grille auto-fit (un SVG par moteur ayant `aggregated_calibration`). Rendu strictement server-side, pas de JS, déterministe. `_build_report_data` expose `aggregated_calibration` par moteur ; `ReportGenerator.generate` calcule les blocs et les passe à `view_analyses.html` qui les affiche **uniquement si ≥ 1 moteur a un `aggregated_calibration`** (rapport adaptatif). Anti-injection HTML via `html.escape`. +13 clés i18n FR/EN. +43 tests dans `test_sprint43_calibration_html.py` couvrant le rendu (résumé, SVG, grille), le masquage adaptatif, l'anti-injection, l'intégration FR + EN, la complétude i18n. **Verrou levé** : A.II.1.b (calibration) est désormais visible bout-en-bout dans le rapport — il manque uniquement l'adaptation effective des engines pour exposer leurs confidences natives (un sprint par adapter : Tesseract `image_to_data`, Pero `PageLayout`, Mistral `confidence`, Google Vision `Word.confidence`, Azure DI). |
|
|
|
|
| 264 |
## Contexte développement
|
| 265 |
|
| 266 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 267 |
+
- **Tests** : 1864 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut ; Sprints 45+46 = stratification A.III livrée bout-en-bout)
|
| 268 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 269 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 270 |
- **Transcript de la conversation de développement** :
|
|
@@ -55,6 +55,10 @@ _FALLBACK_TYPE_ORDER: tuple[FactType, ...] = (
|
|
| 55 |
FactType.STATISTICAL_TIE,
|
| 56 |
FactType.SIGNIFICANT_GAP,
|
| 57 |
FactType.STRATUM_WINNER,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
FactType.STRATUM_COLLAPSE,
|
| 59 |
FactType.ERROR_PROFILE_OUTLIER,
|
| 60 |
FactType.LLM_HALLUCINATION_FLAG,
|
|
@@ -90,6 +94,10 @@ _COMPLEMENTARY_PAIRS: frozenset[frozenset[FactType]] = frozenset({
|
|
| 90 |
# Sprint 44 — l'avertissement d'asymétrie nuance le leader
|
| 91 |
# plutôt que de le doubler : on veut les deux phrases ensemble.
|
| 92 |
frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
})
|
| 94 |
|
| 95 |
|
|
|
|
| 55 |
FactType.STATISTICAL_TIE,
|
| 56 |
FactType.SIGNIFICANT_GAP,
|
| 57 |
FactType.STRATUM_WINNER,
|
| 58 |
+
# Sprint 46 — priority 45, juste après STRATUM_WINNER (40),
|
| 59 |
+
# avant STRATUM_COLLAPSE (50). La recommandation de stratification
|
| 60 |
+
# nuance directement les autres faits par strate.
|
| 61 |
+
FactType.STRATIFICATION_RECOMMENDED,
|
| 62 |
FactType.STRATUM_COLLAPSE,
|
| 63 |
FactType.ERROR_PROFILE_OUTLIER,
|
| 64 |
FactType.LLM_HALLUCINATION_FLAG,
|
|
|
|
| 94 |
# Sprint 44 — l'avertissement d'asymétrie nuance le leader
|
| 95 |
# plutôt que de le doubler : on veut les deux phrases ensemble.
|
| 96 |
frozenset({FactType.GLOBAL_LEADER_CER, FactType.MEDIAN_MEAN_GAP_WARNING}),
|
| 97 |
+
# Sprint 46 — la recommandation de stratification est un méta-conseil
|
| 98 |
+
# qui s'ajoute au leader sans le contredire ; les deux peuvent
|
| 99 |
+
# cohabiter même quand ils concernent le même moteur.
|
| 100 |
+
frozenset({FactType.GLOBAL_LEADER_CER, FactType.STRATIFICATION_RECOMMENDED}),
|
| 101 |
})
|
| 102 |
|
| 103 |
|
|
@@ -776,6 +776,70 @@ def detect_median_mean_gap_warning(benchmark_data: dict) -> list[Fact]:
|
|
| 776 |
)]
|
| 777 |
|
| 778 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
# ---------------------------------------------------------------------------
|
| 780 |
# Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
|
| 781 |
# ---------------------------------------------------------------------------
|
|
|
|
| 776 |
)]
|
| 777 |
|
| 778 |
|
| 779 |
+
# ---------------------------------------------------------------------------
|
| 780 |
+
# Détecteur Sprint 46 — stratification recommandée (corpus hétérogène)
|
| 781 |
+
# ---------------------------------------------------------------------------
|
| 782 |
+
|
| 783 |
+
@register_detector(
|
| 784 |
+
FactType.STRATIFICATION_RECOMMENDED,
|
| 785 |
+
priority=45, # juste après STRATUM_WINNER (40), avant STRATUM_COLLAPSE (50)
|
| 786 |
+
importance=FactImportance.HIGH,
|
| 787 |
+
)
|
| 788 |
+
def detect_stratification_recommended(benchmark_data: dict) -> list[Fact]:
|
| 789 |
+
"""Avertit quand le corpus est hétérogène et que la vue stratifiée
|
| 790 |
+
apporte un éclairage qualitativement différent du classement global.
|
| 791 |
+
|
| 792 |
+
Critère : ``corpus_homogeneity.max_inter_strata_gap > 5 points`` de
|
| 793 |
+
CER médian sur le moteur leader. Au-delà de 10 points, importance
|
| 794 |
+
``HIGH`` (situation très hétérogène où le seul classement global
|
| 795 |
+
serait trompeur).
|
| 796 |
+
|
| 797 |
+
Lit ``benchmark_data["corpus_homogeneity"]`` exposé par
|
| 798 |
+
``BenchmarkResult.as_dict()`` (Sprint 45).
|
| 799 |
+
"""
|
| 800 |
+
homog = benchmark_data.get("corpus_homogeneity")
|
| 801 |
+
if not homog:
|
| 802 |
+
return []
|
| 803 |
+
|
| 804 |
+
gap = homog.get("max_inter_strata_gap")
|
| 805 |
+
if gap is None:
|
| 806 |
+
return []
|
| 807 |
+
|
| 808 |
+
gap = float(gap)
|
| 809 |
+
if gap < 0.05:
|
| 810 |
+
return [] # 5 points de CER : seuil de pertinence éditoriale
|
| 811 |
+
|
| 812 |
+
leader = str(homog.get("leader") or "")
|
| 813 |
+
n_strata = int(homog.get("n_strata") or 0)
|
| 814 |
+
pair = homog.get("leader_max_gap_strata") or ["", ""]
|
| 815 |
+
if len(pair) < 2:
|
| 816 |
+
return []
|
| 817 |
+
min_strat, max_strat = str(pair[0]), str(pair[1])
|
| 818 |
+
|
| 819 |
+
leader_per_stratum = homog.get("leader_per_stratum_median") or {}
|
| 820 |
+
min_med = float(leader_per_stratum.get(min_strat, 0.0))
|
| 821 |
+
max_med = float(leader_per_stratum.get(max_strat, 0.0))
|
| 822 |
+
|
| 823 |
+
importance = (
|
| 824 |
+
FactImportance.HIGH if gap >= 0.10 else FactImportance.MEDIUM
|
| 825 |
+
)
|
| 826 |
+
|
| 827 |
+
return [Fact(
|
| 828 |
+
type=FactType.STRATIFICATION_RECOMMENDED,
|
| 829 |
+
importance=importance,
|
| 830 |
+
payload={
|
| 831 |
+
"leader": leader,
|
| 832 |
+
"n_strata": n_strata,
|
| 833 |
+
"gap_pct": round(gap * 100, 1),
|
| 834 |
+
"min_stratum": min_strat,
|
| 835 |
+
"max_stratum": max_strat,
|
| 836 |
+
"min_stratum_cer_pct": round(min_med * 100, 2),
|
| 837 |
+
"max_stratum_cer_pct": round(max_med * 100, 2),
|
| 838 |
+
},
|
| 839 |
+
engines_involved=(leader,) if leader else (),
|
| 840 |
+
)]
|
| 841 |
+
|
| 842 |
+
|
| 843 |
# ---------------------------------------------------------------------------
|
| 844 |
# Détecteur Sprint 36 — opportunité d'ensemble (complémentarité)
|
| 845 |
# ---------------------------------------------------------------------------
|
|
@@ -70,6 +70,12 @@ class FactType(str, Enum):
|
|
| 70 |
et masque les performances réelles. La médiane (utilisée pour le tri
|
| 71 |
par défaut depuis Sprint 44) est plus représentative."""
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
class FactImportance(int, Enum):
|
| 75 |
"""Score d'importance d'un fait — décide l'ordre et la sélection."""
|
|
|
|
| 70 |
et masque les performances réelles. La médiane (utilisée pour le tri
|
| 71 |
par défaut depuis Sprint 44) est plus représentative."""
|
| 72 |
|
| 73 |
+
STRATIFICATION_RECOMMENDED = "stratification_recommended"
|
| 74 |
+
"""Le corpus est hétérogène du point de vue script_type : le moteur
|
| 75 |
+
leader varie fortement selon la strate. Le lecteur doit consulter
|
| 76 |
+
la vue stratifiée plutôt que de se fier au seul classement global
|
| 77 |
+
(Sprint 46)."""
|
| 78 |
+
|
| 79 |
|
| 80 |
class FactImportance(int, Enum):
|
| 81 |
"""Score d'importance d'un fait — décide l'ordre et la sélection."""
|
|
@@ -68,3 +68,11 @@ median_mean_gap_warning: >-
|
|
| 68 |
{relative_gap_pct} %). The mean is pulled by a few catastrophic
|
| 69 |
documents — the median (now used for default ranking) is more
|
| 70 |
representative.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
{relative_gap_pct} %). The mean is pulled by a few catastrophic
|
| 69 |
documents — the median (now used for default ranking) is more
|
| 70 |
representative.
|
| 71 |
+
|
| 72 |
+
stratification_recommended: >-
|
| 73 |
+
Heterogeneous corpus ({n_strata} strata): {leader} performs very
|
| 74 |
+
differently depending on document type — median CER
|
| 75 |
+
{min_stratum_cer_pct} % on "{min_stratum}" vs
|
| 76 |
+
{max_stratum_cer_pct} % on "{max_stratum}", a gap of {gap_pct}
|
| 77 |
+
points. The global ranking hides this disparity; consult the
|
| 78 |
+
stratified view.
|
|
@@ -72,3 +72,11 @@ median_mean_gap_warning: >-
|
|
| 72 |
{relative_gap_pct} %). La moyenne est tirée par quelques documents
|
| 73 |
catastrophiques — la médiane (utilisée pour le tri par défaut) est
|
| 74 |
plus représentative.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
{relative_gap_pct} %). La moyenne est tirée par quelques documents
|
| 73 |
catastrophiques — la médiane (utilisée pour le tri par défaut) est
|
| 74 |
plus représentative.
|
| 75 |
+
|
| 76 |
+
stratification_recommended: >-
|
| 77 |
+
Corpus hétérogène ({n_strata} strates) : {leader} performe très
|
| 78 |
+
différemment selon le type de document — médiane CER
|
| 79 |
+
{min_stratum_cer_pct} % sur « {min_stratum} » contre
|
| 80 |
+
{max_stratum_cer_pct} % sur « {max_stratum} », soit {gap_pct} points
|
| 81 |
+
d'écart. Le classement global masque cette disparité ; consulter la
|
| 82 |
+
vue stratifiée.
|
|
@@ -572,6 +572,10 @@ def _build_report_data(benchmark: BenchmarkResult, images_b64: dict[str, str]) -
|
|
| 572 |
# Sprint 36 — analyse inter-moteurs (divergence taxonomique +
|
| 573 |
# complémentarité / oracle). ``None`` si moins de 2 moteurs.
|
| 574 |
"inter_engine_analysis": benchmark.inter_engine_analysis,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
}
|
| 576 |
|
| 577 |
|
|
@@ -757,6 +761,18 @@ class ReportGenerator:
|
|
| 757 |
labels=labels,
|
| 758 |
)
|
| 759 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 760 |
env = _build_jinja_env()
|
| 761 |
template = env.get_template("base.html.j2")
|
| 762 |
html = template.render(
|
|
@@ -776,6 +792,7 @@ class ReportGenerator:
|
|
| 776 |
ner_per_category_html=ner_per_category_html,
|
| 777 |
calibration_summary_html=calibration_summary_html,
|
| 778 |
reliability_diagrams_html=reliability_diagrams_html,
|
|
|
|
| 779 |
)
|
| 780 |
|
| 781 |
output_path.write_text(html, encoding="utf-8")
|
|
|
|
| 572 |
# Sprint 36 — analyse inter-moteurs (divergence taxonomique +
|
| 573 |
# complémentarité / oracle). ``None`` si moins de 2 moteurs.
|
| 574 |
"inter_engine_analysis": benchmark.inter_engine_analysis,
|
| 575 |
+
# Sprint 45-46 — stratification par script_type
|
| 576 |
+
"available_strata": benchmark.available_strata(),
|
| 577 |
+
"stratified_ranking": benchmark.stratified_ranking() or None,
|
| 578 |
+
"corpus_homogeneity": benchmark.corpus_homogeneity(),
|
| 579 |
}
|
| 580 |
|
| 581 |
|
|
|
|
| 761 |
labels=labels,
|
| 762 |
)
|
| 763 |
|
| 764 |
+
# Sprint 46 — section stratifiée (tableau par strate). Vide si
|
| 765 |
+
# aucune strate disponible.
|
| 766 |
+
from picarones.report.stratification_render import (
|
| 767 |
+
build_stratified_ranking_html,
|
| 768 |
+
)
|
| 769 |
+
stratified_ranking_html = build_stratified_ranking_html(
|
| 770 |
+
report_data.get("stratified_ranking"),
|
| 771 |
+
report_data.get("available_strata"),
|
| 772 |
+
report_data.get("corpus_homogeneity"),
|
| 773 |
+
labels=labels,
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
env = _build_jinja_env()
|
| 777 |
template = env.get_template("base.html.j2")
|
| 778 |
html = template.render(
|
|
|
|
| 792 |
ner_per_category_html=ner_per_category_html,
|
| 793 |
calibration_summary_html=calibration_summary_html,
|
| 794 |
reliability_diagrams_html=reliability_diagrams_html,
|
| 795 |
+
stratified_ranking_html=stratified_ranking_html,
|
| 796 |
)
|
| 797 |
|
| 798 |
output_path.write_text(html, encoding="utf-8")
|
|
@@ -75,6 +75,14 @@
|
|
| 75 |
"h_characters": "Character Analysis",
|
| 76 |
"h_clusters": "Frequent Error Clusters",
|
| 77 |
"h_correlation": "Metric Correlation Matrix",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"h_calibration": "Engine calibration",
|
| 79 |
"calibration_note": "ECE (Expected Calibration Error): weighted mean of |confidence − accuracy| gaps per bin. The lower the ECE, the more honest the engine is about its reliability — the diagonal in the diagram is perfect calibration. A high ECE means you cannot rely on confidence scores to focus human proofreading.",
|
| 80 |
"calibration_summary_caption": "Engine calibration (ECE, MCE)",
|
|
|
|
| 75 |
"h_characters": "Character Analysis",
|
| 76 |
"h_clusters": "Frequent Error Clusters",
|
| 77 |
"h_correlation": "Metric Correlation Matrix",
|
| 78 |
+
"stratification_caption": "Ranking by stratum (script_type)",
|
| 79 |
+
"stratification_description": "The global table ranks engines across the whole corpus. When the corpus is heterogeneous, some engines dominate on one document type and fail on another — the stratified view reveals this.",
|
| 80 |
+
"stratification_median_label": "Median CER",
|
| 81 |
+
"stratification_mean_label": "Mean CER",
|
| 82 |
+
"stratification_docs_label": "Documents",
|
| 83 |
+
"stratification_no_data_label": "—",
|
| 84 |
+
"stratification_n_docs_label": "documents",
|
| 85 |
+
"stratification_gap_summary": "Leader {leader} inter-stratum gap: {gap_pct} median CER points (between \"{min_stratum}\" and \"{max_stratum}\").",
|
| 86 |
"h_calibration": "Engine calibration",
|
| 87 |
"calibration_note": "ECE (Expected Calibration Error): weighted mean of |confidence − accuracy| gaps per bin. The lower the ECE, the more honest the engine is about its reliability — the diagonal in the diagram is perfect calibration. A high ECE means you cannot rely on confidence scores to focus human proofreading.",
|
| 88 |
"calibration_summary_caption": "Engine calibration (ECE, MCE)",
|
|
@@ -75,6 +75,14 @@
|
|
| 75 |
"h_characters": "Analyse des caractères",
|
| 76 |
"h_clusters": "Clustering des patterns d'erreurs",
|
| 77 |
"h_correlation": "Matrice de corrélation entre métriques",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
"h_calibration": "Calibration des moteurs",
|
| 79 |
"calibration_note": "ECE (Expected Calibration Error) : moyenne pondérée des écarts |confiance − précision| par bin. Plus l'ECE est bas, plus le moteur est honnête sur sa fiabilité — la diagonale du diagramme représente la calibration parfaite. Un ECE élevé signale qu'on ne peut pas se fier au score de confiance pour cibler la relecture humaine.",
|
| 80 |
"calibration_summary_caption": "Calibration des moteurs (ECE, MCE)",
|
|
|
|
| 75 |
"h_characters": "Analyse des caractères",
|
| 76 |
"h_clusters": "Clustering des patterns d'erreurs",
|
| 77 |
"h_correlation": "Matrice de corrélation entre métriques",
|
| 78 |
+
"stratification_caption": "Classement par strate (script_type)",
|
| 79 |
+
"stratification_description": "Le tableau global classe sur l'ensemble du corpus. Quand le corpus est hétérogène, certains moteurs dominent sur un type de document et perdent sur un autre — la vue stratifiée le révèle.",
|
| 80 |
+
"stratification_median_label": "Médiane CER",
|
| 81 |
+
"stratification_mean_label": "Moyenne CER",
|
| 82 |
+
"stratification_docs_label": "Documents",
|
| 83 |
+
"stratification_no_data_label": "—",
|
| 84 |
+
"stratification_n_docs_label": "documents",
|
| 85 |
+
"stratification_gap_summary": "Écart inter-strate du leader {leader} : {gap_pct} points de CER médian (entre « {min_stratum} » et « {max_stratum} »).",
|
| 86 |
"h_calibration": "Calibration des moteurs",
|
| 87 |
"calibration_note": "ECE (Expected Calibration Error) : moyenne pondérée des écarts |confiance − précision| par bin. Plus l'ECE est bas, plus le moteur est honnête sur sa fiabilité — la diagonale du diagramme représente la calibration parfaite. Un ECE élevé signale qu'on ne peut pas se fier au score de confiance pour cibler la relecture humaine.",
|
| 88 |
"calibration_summary_caption": "Calibration des moteurs (ECE, MCE)",
|
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML server-side de la vue stratifiée par script_type (Sprint 46).
|
| 2 |
+
|
| 3 |
+
Suite directe du Sprint 45 (couche backend). Affiche le classement
|
| 4 |
+
moteur par strate sous forme de tableaux pliables (HTML ``<details>``,
|
| 5 |
+
pas de JavaScript).
|
| 6 |
+
|
| 7 |
+
- ``build_stratified_ranking_html`` — un ``<details>`` par strate avec
|
| 8 |
+
tableau ``moteur, médiane, moyenne, docs``. Cellule médiane colorée
|
| 9 |
+
par gradient vert (faible CER) → rouge (CER élevé).
|
| 10 |
+
|
| 11 |
+
Principe : cohérent avec ``inter_engine_render``, ``ner_render`` et
|
| 12 |
+
``calibration_render`` — server-side, déterministe, pas de JS.
|
| 13 |
+
Masquage adaptatif : la fonction retourne ``""`` si aucune strate
|
| 14 |
+
n'est disponible (``available_strata`` vide).
|
| 15 |
+
|
| 16 |
+
Anti-injection : tous les noms de moteurs et de strates sont passés
|
| 17 |
+
à ``html.escape``.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from html import escape as _e
|
| 23 |
+
from typing import Optional
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _color_for_cer(cer: float) -> str:
|
| 27 |
+
"""Gradient vert (faible CER) → rouge (CER élevé), saturé à 0.30."""
|
| 28 |
+
f = max(0.0, min(1.0, cer / 0.30))
|
| 29 |
+
if f <= 0.5:
|
| 30 |
+
ratio = f / 0.5
|
| 31 |
+
r = int(130 + (240 - 130) * ratio)
|
| 32 |
+
g = int(200 + (220 - 200) * ratio)
|
| 33 |
+
b = int(130 + (130 - 130) * ratio)
|
| 34 |
+
else:
|
| 35 |
+
ratio = (f - 0.5) / 0.5
|
| 36 |
+
r = int(240 + (220 - 240) * ratio)
|
| 37 |
+
g = int(220 + (100 - 220) * ratio)
|
| 38 |
+
b = int(130 + (100 - 130) * ratio)
|
| 39 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _format_cer(cer: Optional[float]) -> str:
|
| 43 |
+
if cer is None:
|
| 44 |
+
return "—"
|
| 45 |
+
return f"{cer * 100:.2f} %"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def build_stratified_ranking_html(
|
| 49 |
+
stratified_ranking: Optional[dict],
|
| 50 |
+
available_strata: Optional[list],
|
| 51 |
+
homogeneity: Optional[dict] = None,
|
| 52 |
+
labels: Optional[dict[str, str]] = None,
|
| 53 |
+
) -> str:
|
| 54 |
+
"""Construit la section HTML stratifiée.
|
| 55 |
+
|
| 56 |
+
Parameters
|
| 57 |
+
----------
|
| 58 |
+
stratified_ranking:
|
| 59 |
+
``{stratum: [ranking_entry, …]}`` produit par
|
| 60 |
+
``BenchmarkResult.stratified_ranking()``.
|
| 61 |
+
available_strata:
|
| 62 |
+
Liste triée des strates (``BenchmarkResult.available_strata()``).
|
| 63 |
+
homogeneity:
|
| 64 |
+
Dict produit par ``BenchmarkResult.corpus_homogeneity()`` si
|
| 65 |
+
disponible — sert à afficher l'écart inter-strate du leader
|
| 66 |
+
en tête de section.
|
| 67 |
+
labels:
|
| 68 |
+
i18n. Fallback FR si manquantes.
|
| 69 |
+
|
| 70 |
+
Returns
|
| 71 |
+
-------
|
| 72 |
+
str
|
| 73 |
+
HTML ``<div>...</div>`` ou ``""`` si stratification absente.
|
| 74 |
+
"""
|
| 75 |
+
if not stratified_ranking or not available_strata:
|
| 76 |
+
return ""
|
| 77 |
+
|
| 78 |
+
labels = labels or {}
|
| 79 |
+
caption = labels.get(
|
| 80 |
+
"stratification_caption",
|
| 81 |
+
"Classement par strate (script_type)",
|
| 82 |
+
)
|
| 83 |
+
description = labels.get(
|
| 84 |
+
"stratification_description",
|
| 85 |
+
"Le tableau global classe sur l'ensemble du corpus. Quand le "
|
| 86 |
+
"corpus est hétérogène, certains moteurs dominent sur un type "
|
| 87 |
+
"de document et perdent sur un autre — la vue stratifiée le "
|
| 88 |
+
"révèle.",
|
| 89 |
+
)
|
| 90 |
+
engine_label = labels.get("col_engine", "Moteur")
|
| 91 |
+
median_label = labels.get("stratification_median_label", "Médiane CER")
|
| 92 |
+
mean_label = labels.get("stratification_mean_label", "Moyenne CER")
|
| 93 |
+
docs_label = labels.get("stratification_docs_label", "Documents")
|
| 94 |
+
no_data = labels.get("stratification_no_data_label", "—")
|
| 95 |
+
n_docs_in_stratum_label = labels.get(
|
| 96 |
+
"stratification_n_docs_label", "documents",
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
parts: list[str] = []
|
| 100 |
+
parts.append('<div class="stratified-ranking" style="margin-top:1.2rem">')
|
| 101 |
+
parts.append(
|
| 102 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(caption)}</h3>'
|
| 103 |
+
)
|
| 104 |
+
parts.append(
|
| 105 |
+
f'<div style="font-size:.78rem;color:var(--text-muted);'
|
| 106 |
+
f'margin-bottom:.6rem">{_e(description)}</div>'
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Bandeau d'hétérogénéité si disponible
|
| 110 |
+
if homogeneity and homogeneity.get("max_inter_strata_gap") is not None:
|
| 111 |
+
gap = float(homogeneity["max_inter_strata_gap"])
|
| 112 |
+
leader = str(homogeneity.get("leader") or "")
|
| 113 |
+
min_strat, max_strat = homogeneity.get(
|
| 114 |
+
"leader_max_gap_strata", ["", ""]
|
| 115 |
+
)
|
| 116 |
+
gap_template = labels.get(
|
| 117 |
+
"stratification_gap_summary",
|
| 118 |
+
"Écart inter-strate du leader {leader} : {gap_pct} points "
|
| 119 |
+
"de CER médian (entre « {min_stratum} » et « {max_stratum} »).",
|
| 120 |
+
)
|
| 121 |
+
gap_text = gap_template.format(
|
| 122 |
+
leader=leader,
|
| 123 |
+
gap_pct=f"{gap * 100:.1f}",
|
| 124 |
+
min_stratum=min_strat,
|
| 125 |
+
max_stratum=max_strat,
|
| 126 |
+
)
|
| 127 |
+
# gap_text contient déjà des données utilisateur — on n'échappe pas
|
| 128 |
+
# le template lui-même (i18n connue), mais on n'injecte pas non plus
|
| 129 |
+
# de markup. _e() est appliqué aux variables via format() côté template.
|
| 130 |
+
parts.append(
|
| 131 |
+
f'<div style="font-size:.82rem;background:#fff8e1;'
|
| 132 |
+
f'border-left:3px solid #f9a825;padding:.4rem .6rem;'
|
| 133 |
+
f'margin-bottom:.6rem">⚠ {_e(gap_text)}</div>'
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# Une ``<details>`` par strate (premier ouvert pour donner le contexte)
|
| 137 |
+
for i, stratum in enumerate(available_strata):
|
| 138 |
+
entries = stratified_ranking.get(stratum) or []
|
| 139 |
+
n_docs_total = max((int(e.get("documents") or 0) for e in entries), default=0)
|
| 140 |
+
open_attr = " open" if i == 0 else ""
|
| 141 |
+
parts.append(
|
| 142 |
+
f'<details class="stratum-block"{open_attr} '
|
| 143 |
+
f'style="margin-bottom:.4rem;border:1px solid var(--border);'
|
| 144 |
+
f'border-radius:6px;padding:.4rem .6rem">'
|
| 145 |
+
)
|
| 146 |
+
parts.append(
|
| 147 |
+
f'<summary style="cursor:pointer;font-weight:600">'
|
| 148 |
+
f'{_e(stratum)} '
|
| 149 |
+
f'<span style="font-weight:400;color:var(--text-muted);'
|
| 150 |
+
f'font-size:.85rem">({n_docs_total} {_e(n_docs_in_stratum_label)})</span>'
|
| 151 |
+
f'</summary>'
|
| 152 |
+
)
|
| 153 |
+
parts.append(
|
| 154 |
+
'<table style="border-collapse:collapse;font-size:.85rem;'
|
| 155 |
+
'margin-top:.4rem;width:100%">'
|
| 156 |
+
)
|
| 157 |
+
parts.append("<thead><tr>")
|
| 158 |
+
for hdr in (engine_label, median_label, mean_label, docs_label):
|
| 159 |
+
parts.append(
|
| 160 |
+
f'<th style="padding:.3rem .5rem;text-align:left;'
|
| 161 |
+
f'border-bottom:1px solid var(--border);font-weight:600">'
|
| 162 |
+
f'{_e(hdr)}</th>'
|
| 163 |
+
)
|
| 164 |
+
parts.append("</tr></thead><tbody>")
|
| 165 |
+
for entry in entries:
|
| 166 |
+
engine = str(entry.get("engine", ""))
|
| 167 |
+
median = entry.get("median_cer")
|
| 168 |
+
mean = entry.get("mean_cer")
|
| 169 |
+
n_docs = int(entry.get("documents") or 0)
|
| 170 |
+
bg = _color_for_cer(float(median)) if median is not None else "#f4f4f4"
|
| 171 |
+
parts.append("<tr>")
|
| 172 |
+
parts.append(
|
| 173 |
+
f'<td style="padding:.3rem .5rem;font-weight:600">'
|
| 174 |
+
f'{_e(engine)}</td>'
|
| 175 |
+
)
|
| 176 |
+
parts.append(
|
| 177 |
+
f'<td style="padding:.3rem .5rem;background:{bg};'
|
| 178 |
+
f'font-variant-numeric:tabular-nums">'
|
| 179 |
+
f'{_e(_format_cer(median)) if median is not None else _e(no_data)}'
|
| 180 |
+
f'</td>'
|
| 181 |
+
)
|
| 182 |
+
parts.append(
|
| 183 |
+
f'<td style="padding:.3rem .5rem;'
|
| 184 |
+
f'font-variant-numeric:tabular-nums">'
|
| 185 |
+
f'{_e(_format_cer(mean)) if mean is not None else _e(no_data)}'
|
| 186 |
+
f'</td>'
|
| 187 |
+
)
|
| 188 |
+
parts.append(
|
| 189 |
+
f'<td style="padding:.3rem .5rem;'
|
| 190 |
+
f'font-variant-numeric:tabular-nums">{n_docs}</td>'
|
| 191 |
+
)
|
| 192 |
+
parts.append("</tr>")
|
| 193 |
+
parts.append("</tbody></table>")
|
| 194 |
+
parts.append("</details>")
|
| 195 |
+
|
| 196 |
+
parts.append("</div>")
|
| 197 |
+
return "".join(parts)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
__all__ = [
|
| 201 |
+
"build_stratified_ranking_html",
|
| 202 |
+
]
|
|
@@ -43,6 +43,12 @@
|
|
| 43 |
<span class="legend-dot" style="background:#dc2626"></span>> 30 %
|
| 44 |
</div>
|
| 45 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
|
| 48 |
<!-- ── Métriques robustes ────────────────────────────────────── -->
|
|
|
|
| 43 |
<span class="legend-dot" style="background:#dc2626"></span>> 30 %
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
+
|
| 47 |
+
<!-- Sprint 46 — vue stratifiée par script_type (rapport adaptatif :
|
| 48 |
+
section omise quand aucune strate n'est disponible) -->
|
| 49 |
+
{% if stratified_ranking_html %}
|
| 50 |
+
{{ stratified_ranking_html }}
|
| 51 |
+
{% endif %}
|
| 52 |
</div>
|
| 53 |
|
| 54 |
<!-- ── Métriques robustes ────────────────────────────────────── -->
|
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 46 — vue HTML stratifiée + détecteur narratif.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``build_stratified_ranking_html`` rend un ``<details>`` par strate
|
| 6 |
+
avec tableau moteur × (médiane, moyenne, docs).
|
| 7 |
+
2. Bandeau d'hétérogénéité affiché si ``corpus_homogeneity`` fourni.
|
| 8 |
+
3. **Masquage adaptatif** : retourne ``""`` si pas de strates.
|
| 9 |
+
4. **Anti-injection** : noms de strates et de moteurs avec balises
|
| 10 |
+
HTML sont échappés.
|
| 11 |
+
5. **Détecteur ``STRATIFICATION_RECOMMENDED``** :
|
| 12 |
+
- se déclenche au-delà de 5 points d'écart inter-strate
|
| 13 |
+
- importance HIGH au-delà de 10 points, MEDIUM sinon
|
| 14 |
+
- ne se déclenche pas sans corpus_homogeneity
|
| 15 |
+
6. **Anti-hallucination** : chaque nombre rendu est dans le payload.
|
| 16 |
+
7. **Intégration ReportGenerator** : la section apparaît dans
|
| 17 |
+
``view_ranking`` quand ``doc_strata`` est peuplé.
|
| 18 |
+
8. **i18n FR/EN** : clés présentes pour la vue + le template narratif.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
import re
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
import pytest
|
| 28 |
+
|
| 29 |
+
from picarones.core.metrics import MetricsResult
|
| 30 |
+
from picarones.core.narrative.detectors import detect_stratification_recommended
|
| 31 |
+
from picarones.core.narrative.facts import FactImportance, FactType
|
| 32 |
+
from picarones.core.narrative.renderer import extract_numbers, render_fact
|
| 33 |
+
from picarones.core.results import DocumentResult
|
| 34 |
+
from picarones.report.generator import ReportGenerator
|
| 35 |
+
from picarones.report.stratification_render import build_stratified_ranking_html
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 39 |
+
# Helpers
|
| 40 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
_SAMPLE_STRAT = {
|
| 44 |
+
"gothique": [
|
| 45 |
+
{"engine": "pero", "median_cer": 0.05, "mean_cer": 0.07, "documents": 10},
|
| 46 |
+
{"engine": "tess", "median_cer": 0.20, "mean_cer": 0.22, "documents": 10},
|
| 47 |
+
],
|
| 48 |
+
"imprimé": [
|
| 49 |
+
{"engine": "tess", "median_cer": 0.02, "mean_cer": 0.03, "documents": 10},
|
| 50 |
+
{"engine": "pero", "median_cer": 0.05, "mean_cer": 0.06, "documents": 10},
|
| 51 |
+
],
|
| 52 |
+
}
|
| 53 |
+
_SAMPLE_STRATA = ["gothique", "imprimé"]
|
| 54 |
+
_SAMPLE_HOMOG = {
|
| 55 |
+
"leader": "tess",
|
| 56 |
+
"n_strata": 2,
|
| 57 |
+
"max_inter_strata_gap": 0.18,
|
| 58 |
+
"leader_max_gap_strata": ["imprimé", "gothique"],
|
| 59 |
+
"leader_per_stratum_median": {"imprimé": 0.02, "gothique": 0.20},
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _make_dr(doc_id: str, cer: float) -> DocumentResult:
|
| 64 |
+
return DocumentResult(
|
| 65 |
+
doc_id=doc_id, image_path=f"/tmp/{doc_id}.png",
|
| 66 |
+
ground_truth="x", hypothesis="x",
|
| 67 |
+
metrics=MetricsResult(
|
| 68 |
+
cer=cer, cer_nfc=cer, cer_caseless=cer,
|
| 69 |
+
wer=cer, wer_normalized=cer, mer=cer, wil=cer,
|
| 70 |
+
reference_length=1, hypothesis_length=1,
|
| 71 |
+
),
|
| 72 |
+
duration_seconds=0.1,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 79 |
+
# 1-2. build_stratified_ranking_html
|
| 80 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class TestRendering:
|
| 84 |
+
def test_renders_one_details_per_stratum(self) -> None:
|
| 85 |
+
html = build_stratified_ranking_html(
|
| 86 |
+
_SAMPLE_STRAT, _SAMPLE_STRATA, _SAMPLE_HOMOG,
|
| 87 |
+
)
|
| 88 |
+
assert html.count("<details") == 2
|
| 89 |
+
# Premier ouvert
|
| 90 |
+
assert "<details" in html and " open" in html
|
| 91 |
+
|
| 92 |
+
def test_includes_engine_metrics(self) -> None:
|
| 93 |
+
html = build_stratified_ranking_html(
|
| 94 |
+
_SAMPLE_STRAT, _SAMPLE_STRATA, _SAMPLE_HOMOG,
|
| 95 |
+
)
|
| 96 |
+
# Médianes en pourcentage
|
| 97 |
+
assert "5.00 %" in html # pero gothique
|
| 98 |
+
assert "20.00 %" in html # tess gothique
|
| 99 |
+
assert "2.00 %" in html # tess imprimé
|
| 100 |
+
|
| 101 |
+
def test_homogeneity_banner_present(self) -> None:
|
| 102 |
+
html = build_stratified_ranking_html(
|
| 103 |
+
_SAMPLE_STRAT, _SAMPLE_STRATA, _SAMPLE_HOMOG,
|
| 104 |
+
)
|
| 105 |
+
# Le bandeau d'avertissement doit apparaître
|
| 106 |
+
assert "tess" in html
|
| 107 |
+
assert "18.0" in html
|
| 108 |
+
|
| 109 |
+
def test_no_homogeneity_no_banner(self) -> None:
|
| 110 |
+
html = build_stratified_ranking_html(
|
| 111 |
+
_SAMPLE_STRAT, _SAMPLE_STRATA, homogeneity=None,
|
| 112 |
+
)
|
| 113 |
+
# Pas de bandeau jaune
|
| 114 |
+
assert "#fff8e1" not in html
|
| 115 |
+
|
| 116 |
+
def test_uses_i18n_labels(self) -> None:
|
| 117 |
+
labels = {
|
| 118 |
+
"stratification_caption": "CUSTOM_CAPTION",
|
| 119 |
+
"stratification_median_label": "MED",
|
| 120 |
+
"stratification_mean_label": "MEAN",
|
| 121 |
+
}
|
| 122 |
+
html = build_stratified_ranking_html(
|
| 123 |
+
_SAMPLE_STRAT, _SAMPLE_STRATA, None, labels=labels,
|
| 124 |
+
)
|
| 125 |
+
assert "CUSTOM_CAPTION" in html
|
| 126 |
+
assert "MED" in html
|
| 127 |
+
assert "MEAN" in html
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 131 |
+
# 3. Masquage adaptatif
|
| 132 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
class TestAdaptiveMasking:
|
| 136 |
+
def test_empty_when_no_stratified_ranking(self) -> None:
|
| 137 |
+
assert build_stratified_ranking_html(None, ["S1"]) == ""
|
| 138 |
+
assert build_stratified_ranking_html({}, ["S1"]) == ""
|
| 139 |
+
|
| 140 |
+
def test_empty_when_no_available_strata(self) -> None:
|
| 141 |
+
assert build_stratified_ranking_html(_SAMPLE_STRAT, None) == ""
|
| 142 |
+
assert build_stratified_ranking_html(_SAMPLE_STRAT, []) == ""
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 146 |
+
# 4. Anti-injection
|
| 147 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
class TestAntiInjection:
|
| 151 |
+
def test_engine_name_escaped(self) -> None:
|
| 152 |
+
bad_strat = {
|
| 153 |
+
"S1": [
|
| 154 |
+
{"engine": "<script>alert(1)</script>",
|
| 155 |
+
"median_cer": 0.1, "mean_cer": 0.1, "documents": 1},
|
| 156 |
+
],
|
| 157 |
+
}
|
| 158 |
+
html = build_stratified_ranking_html(bad_strat, ["S1"])
|
| 159 |
+
assert "<script>" not in html
|
| 160 |
+
assert "<script>" in html
|
| 161 |
+
|
| 162 |
+
def test_stratum_name_escaped(self) -> None:
|
| 163 |
+
bad_strat = {
|
| 164 |
+
"<img src=x>": [
|
| 165 |
+
{"engine": "a", "median_cer": 0.1,
|
| 166 |
+
"mean_cer": 0.1, "documents": 1},
|
| 167 |
+
],
|
| 168 |
+
}
|
| 169 |
+
html = build_stratified_ranking_html(bad_strat, ["<img src=x>"])
|
| 170 |
+
assert "<img src=x>" not in html
|
| 171 |
+
assert "<img" in html
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 175 |
+
# 5. Détecteur STRATIFICATION_RECOMMENDED
|
| 176 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _data(gap: float, **overrides) -> dict:
|
| 180 |
+
homog = {
|
| 181 |
+
"leader": "tess", "n_strata": 2,
|
| 182 |
+
"max_inter_strata_gap": gap,
|
| 183 |
+
"leader_max_gap_strata": ["S1", "S2"],
|
| 184 |
+
"leader_per_stratum_median": {"S1": 0.02, "S2": 0.02 + gap},
|
| 185 |
+
}
|
| 186 |
+
homog.update(overrides)
|
| 187 |
+
return {"corpus_homogeneity": homog}
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class TestStratificationDetector:
|
| 191 |
+
def test_no_fact_below_threshold(self) -> None:
|
| 192 |
+
# 4 points → en dessous du seuil 5 points
|
| 193 |
+
assert detect_stratification_recommended(_data(0.04)) == []
|
| 194 |
+
|
| 195 |
+
def test_emits_fact_above_threshold(self) -> None:
|
| 196 |
+
facts = detect_stratification_recommended(_data(0.07))
|
| 197 |
+
assert len(facts) == 1
|
| 198 |
+
assert facts[0].type is FactType.STRATIFICATION_RECOMMENDED
|
| 199 |
+
|
| 200 |
+
def test_medium_below_10pts(self) -> None:
|
| 201 |
+
facts = detect_stratification_recommended(_data(0.07))
|
| 202 |
+
assert facts[0].importance is FactImportance.MEDIUM
|
| 203 |
+
|
| 204 |
+
def test_high_above_10pts(self) -> None:
|
| 205 |
+
facts = detect_stratification_recommended(_data(0.18))
|
| 206 |
+
assert facts[0].importance is FactImportance.HIGH
|
| 207 |
+
|
| 208 |
+
def test_no_homogeneity_no_fact(self) -> None:
|
| 209 |
+
assert detect_stratification_recommended({}) == []
|
| 210 |
+
assert detect_stratification_recommended({"corpus_homogeneity": None}) == []
|
| 211 |
+
|
| 212 |
+
def test_payload_carries_strata_and_cers(self) -> None:
|
| 213 |
+
facts = detect_stratification_recommended(_data(0.18))
|
| 214 |
+
p = facts[0].payload
|
| 215 |
+
assert p["leader"] == "tess"
|
| 216 |
+
assert p["n_strata"] == 2
|
| 217 |
+
assert p["min_stratum"] == "S1"
|
| 218 |
+
assert p["max_stratum"] == "S2"
|
| 219 |
+
assert p["gap_pct"] == 18.0
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 223 |
+
# 6. Anti-hallucination
|
| 224 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
class TestTraceability:
|
| 228 |
+
@pytest.mark.parametrize("lang", ["fr", "en"])
|
| 229 |
+
def test_every_rendered_number_is_in_payload(self, lang: str) -> None:
|
| 230 |
+
# On utilise des noms de strates sans chiffres (la traçabilité
|
| 231 |
+
# exige que tout chiffre rendu vienne du payload, mais les
|
| 232 |
+
# noms de strates côté GT peuvent légitimement contenir des
|
| 233 |
+
# chiffres ; pour le test on isole les nombres "métriques").
|
| 234 |
+
data = {"corpus_homogeneity": {
|
| 235 |
+
"leader": "tess", "n_strata": 2,
|
| 236 |
+
"max_inter_strata_gap": 0.18,
|
| 237 |
+
"leader_max_gap_strata": ["impr", "goth"],
|
| 238 |
+
"leader_per_stratum_median": {"impr": 0.02, "goth": 0.20},
|
| 239 |
+
}}
|
| 240 |
+
facts = detect_stratification_recommended(data)
|
| 241 |
+
sentence = render_fact(facts[0], lang)
|
| 242 |
+
|
| 243 |
+
payload_nums: set[str] = set()
|
| 244 |
+
for v in facts[0].payload.values():
|
| 245 |
+
if isinstance(v, (int, float)):
|
| 246 |
+
payload_nums.add(str(v))
|
| 247 |
+
if isinstance(v, float) and v.is_integer():
|
| 248 |
+
payload_nums.add(str(int(v)))
|
| 249 |
+
elif isinstance(v, str):
|
| 250 |
+
# Capture aussi les chiffres présents dans les chaînes
|
| 251 |
+
# du payload (ex. noms de strates contenant un nombre)
|
| 252 |
+
for match in re.findall(r"\d+(?:[.,]\d+)?", v):
|
| 253 |
+
payload_nums.add(match.replace(",", "."))
|
| 254 |
+
|
| 255 |
+
for num in extract_numbers(sentence):
|
| 256 |
+
normalized = num.replace(",", ".")
|
| 257 |
+
assert normalized in payload_nums, (
|
| 258 |
+
f"Nombre {normalized!r} non traçable au payload "
|
| 259 |
+
f"{facts[0].payload!r}"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
def test_template_has_no_hardcoded_numbers(self) -> None:
|
| 263 |
+
from picarones.core.narrative.renderer import _load_templates
|
| 264 |
+
for lang in ("fr", "en"):
|
| 265 |
+
tpl = _load_templates(lang).get("stratification_recommended", "")
|
| 266 |
+
assert tpl, f"Template absent pour {lang}"
|
| 267 |
+
cleaned = re.sub(r"\{[^}]+\}", "", tpl)
|
| 268 |
+
digits = re.findall(r"\d", cleaned)
|
| 269 |
+
assert not digits, f"Template {lang} contient des chiffres en dur : {digits}"
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 273 |
+
# 7. Intégration ReportGenerator
|
| 274 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class TestReportIntegration:
|
| 278 |
+
def test_section_absent_without_strata(self, tmp_path: Path) -> None:
|
| 279 |
+
from picarones.fixtures import generate_sample_benchmark
|
| 280 |
+
bench = generate_sample_benchmark()
|
| 281 |
+
bench.doc_strata = None # force absence
|
| 282 |
+
out = tmp_path / "report.html"
|
| 283 |
+
ReportGenerator(bench).generate(out)
|
| 284 |
+
html = out.read_text(encoding="utf-8")
|
| 285 |
+
assert "stratified-ranking" not in html
|
| 286 |
+
|
| 287 |
+
def test_section_present_with_strata(self, tmp_path: Path) -> None:
|
| 288 |
+
from picarones.fixtures import generate_sample_benchmark
|
| 289 |
+
bench = generate_sample_benchmark()
|
| 290 |
+
# La fixture peuple image_quality.script_type ; on extrait
|
| 291 |
+
# manuellement comme le ferait le runner.
|
| 292 |
+
strata_map: dict[str, str] = {}
|
| 293 |
+
for r in bench.engine_reports:
|
| 294 |
+
for dr in r.document_results:
|
| 295 |
+
if dr.image_quality and dr.image_quality.get("script_type"):
|
| 296 |
+
strata_map.setdefault(dr.doc_id, dr.image_quality["script_type"])
|
| 297 |
+
bench.doc_strata = strata_map
|
| 298 |
+
|
| 299 |
+
out = tmp_path / "report.html"
|
| 300 |
+
ReportGenerator(bench).generate(out)
|
| 301 |
+
html = out.read_text(encoding="utf-8")
|
| 302 |
+
assert "stratified-ranking" in html
|
| 303 |
+
# Au moins un <details> rendu
|
| 304 |
+
assert "<details" in html
|
| 305 |
+
|
| 306 |
+
def test_french_locale_uses_french_labels(self, tmp_path: Path) -> None:
|
| 307 |
+
from picarones.fixtures import generate_sample_benchmark
|
| 308 |
+
bench = generate_sample_benchmark()
|
| 309 |
+
strata_map = {}
|
| 310 |
+
for r in bench.engine_reports:
|
| 311 |
+
for dr in r.document_results:
|
| 312 |
+
if dr.image_quality and dr.image_quality.get("script_type"):
|
| 313 |
+
strata_map.setdefault(dr.doc_id, dr.image_quality["script_type"])
|
| 314 |
+
bench.doc_strata = strata_map
|
| 315 |
+
|
| 316 |
+
out = tmp_path / "report_fr.html"
|
| 317 |
+
ReportGenerator(bench, lang="fr").generate(out)
|
| 318 |
+
html = out.read_text(encoding="utf-8")
|
| 319 |
+
assert "Classement par strate" in html
|
| 320 |
+
assert "Médiane CER" in html
|
| 321 |
+
|
| 322 |
+
def test_english_locale_uses_english_labels(self, tmp_path: Path) -> None:
|
| 323 |
+
from picarones.fixtures import generate_sample_benchmark
|
| 324 |
+
bench = generate_sample_benchmark()
|
| 325 |
+
strata_map = {}
|
| 326 |
+
for r in bench.engine_reports:
|
| 327 |
+
for dr in r.document_results:
|
| 328 |
+
if dr.image_quality and dr.image_quality.get("script_type"):
|
| 329 |
+
strata_map.setdefault(dr.doc_id, dr.image_quality["script_type"])
|
| 330 |
+
bench.doc_strata = strata_map
|
| 331 |
+
|
| 332 |
+
out = tmp_path / "report_en.html"
|
| 333 |
+
ReportGenerator(bench, lang="en").generate(out)
|
| 334 |
+
html = out.read_text(encoding="utf-8")
|
| 335 |
+
assert "Ranking by stratum" in html
|
| 336 |
+
assert "Median CER" in html
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 340 |
+
# 8. i18n FR/EN
|
| 341 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
REQUIRED_KEYS = (
|
| 345 |
+
"stratification_caption",
|
| 346 |
+
"stratification_description",
|
| 347 |
+
"stratification_median_label",
|
| 348 |
+
"stratification_mean_label",
|
| 349 |
+
"stratification_docs_label",
|
| 350 |
+
"stratification_no_data_label",
|
| 351 |
+
"stratification_n_docs_label",
|
| 352 |
+
"stratification_gap_summary",
|
| 353 |
+
)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
class TestI18NCompleteness:
|
| 357 |
+
@pytest.mark.parametrize("lang", ["fr", "en"])
|
| 358 |
+
@pytest.mark.parametrize("key", REQUIRED_KEYS)
|
| 359 |
+
def test_key_present(self, lang: str, key: str) -> None:
|
| 360 |
+
path = (
|
| 361 |
+
Path(__file__).parent.parent
|
| 362 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 363 |
+
)
|
| 364 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 365 |
+
assert key in data, f"Clé {key!r} manquante dans {lang}.json"
|
| 366 |
+
assert data[key].strip(), f"Clé {key!r} vide dans {lang}.json"
|