Claude commited on
Commit
6fef74e
·
unverified ·
1 Parent(s): ba5cf04

sprint46: A.III stratification — vue HTML + détecteur narratif (clôture A.III)

Browse files

Suite 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 CHANGED
@@ -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 → 1826 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
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). Aucune régression.
445
- **Phase 0 close ; Étape 2 du plan d'évolution : inter-moteurs
446
- (A.II.1.c), NER (A.II.1.a) et calibration (A.II.1.b) livrés
447
- bout-en-bout calcul → runner → HTML ; A.I.2 médiane par défaut
448
- livré (Sprint 44) ; A.III stratification couche backend livrée
449
- (Sprint 45), vue HTML à venir. Reste l'adaptation effective des
450
- engines pour exposer leurs confidences natives (un sprint par
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
 
CLAUDE.md CHANGED
@@ -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** : 1826 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 ; Sprint 45 = stratification couche backend)
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** :
picarones/core/narrative/arbiter.py CHANGED
@@ -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
 
picarones/core/narrative/detectors.py CHANGED
@@ -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
  # ---------------------------------------------------------------------------
picarones/core/narrative/facts.py CHANGED
@@ -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."""
picarones/core/narrative/templates/en.yaml CHANGED
@@ -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.
picarones/core/narrative/templates/fr.yaml CHANGED
@@ -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.
picarones/report/generator.py CHANGED
@@ -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")
picarones/report/i18n/en.json CHANGED
@@ -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)",
picarones/report/i18n/fr.json CHANGED
@@ -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)",
picarones/report/stratification_render.py ADDED
@@ -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
+ ]
picarones/report/templates/view_ranking.html CHANGED
@@ -43,6 +43,12 @@
43
  <span class="legend-dot" style="background:#dc2626"></span>&gt; 30 %
44
  </div>
45
  </div>
 
 
 
 
 
 
46
  </div>
47
 
48
  <!-- ── Métriques robustes ────────────────────────────────────── -->
 
43
  <span class="legend-dot" style="background:#dc2626"></span>&gt; 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 ────────────────────────────────────── -->
tests/test_sprint46_stratification_html.py ADDED
@@ -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 "&lt;script&gt;" 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 "&lt;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"