Spaces:
Running
sprint82: section "Leviers d'amélioration" (A.I.9 bout-en-bout)
Browse filesLe moteur narratif Sprint 19 décrit ce qui s'est passé ; ce sprint
identifie sur quelle dimension un effort éditorial pourrait porter,
purement factuel jamais prescriptif.
- picarones/core/levers.py : dataclass Lever + LeverImportance
+ registre via @register_lever + 5 détecteurs
(dominant_recoverable_class, pareto_concentration,
complementarity_observation, lexical_modernization_observation,
robustness_projection_observation).
- picarones/report/levers_render.py : cards server-side, anti-injection,
adaptive masking, garde-fou anti-hallucination identique au moteur
narratif (chaque chiffre rendu provient du payload).
- 18 clés i18n FR/EN (levers_*).
- 40 tests : modèle, 5 détecteurs individuels, pipeline,
rendu HTML, anti-hallucination FR+EN, complétude i18n.
Tests : 2761 passed, 2 skipped.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +38 -0
- CLAUDE.md +2 -1
- picarones/core/levers.py +561 -0
- picarones/report/i18n/en.json +19 -1
- picarones/report/i18n/fr.json +19 -1
- picarones/report/levers_render.py +276 -0
- tests/test_sprint82_levers.py +575 -0
|
@@ -16,6 +16,44 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 81 — A.I.8 : robustesse synthétique projetée sur le
|
| 20 |
corpus réel (couche de calcul).** Le module
|
| 21 |
``picarones/core/robustness.py`` (Sprint 8) génère des courbes
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 82 — A.I.9 : section « Leviers d'amélioration »
|
| 20 |
+
(couche calcul + cards HTML).** Le moteur narratif
|
| 21 |
+
(Sprint 19) émet des `Fact` qui décrivent **ce qui s'est
|
| 22 |
+
passé** dans le benchmark. Ce sprint répond à une question
|
| 23 |
+
complémentaire : *« sur quelle dimension le bénéfice attendu
|
| 24 |
+
d'une amélioration serait-il le plus visible ? »*. Approche
|
| 25 |
+
strictement **non-prescriptive** : aucune recommandation
|
| 26 |
+
*« faites X »*, uniquement des **observations factuelles**
|
| 27 |
+
agrégées depuis les modules d'analyse (Sprints 75-81).
|
| 28 |
+
Nouveau module `picarones/core/levers.py` : dataclass
|
| 29 |
+
``Lever(type, importance, payload, engines_involved)``,
|
| 30 |
+
``LeverImportance`` (HIGH/MEDIUM/LOW), registre via
|
| 31 |
+
décorateur ``@register_lever``, helper ``detect_levers`` qui
|
| 32 |
+
trie par importance décroissante. **5 détecteurs livrés** :
|
| 33 |
+
``dominant_recoverable_class`` (≥30 % d'erreurs récupérables
|
| 34 |
+
selon la catégorisation Sprint 77), ``pareto_concentration``
|
| 35 |
+
(top-20 % docs ≥50 % du CER cumulé), ``complementarity_observation``
|
| 36 |
+
(factuel sur ``inter_engine_analysis.complementarity_gap``,
|
| 37 |
+
Sprint 35), ``lexical_modernization_observation`` (top-3
|
| 38 |
+
tokens GT systématiquement modernisés, Sprint 80),
|
| 39 |
+
``robustness_projection_observation`` (déficit projeté ≥2
|
| 40 |
+
points de CER, Sprint 81). Nouveau module
|
| 41 |
+
`picarones/report/levers_render.py` : ``build_levers_section_html``
|
| 42 |
+
rend des **cards** server-side avec étiquette i18n + phrase
|
| 43 |
+
factuelle + détail compact + niveau d'importance coloré.
|
| 44 |
+
Adaptive masking : ``""`` si aucun levier exploitable.
|
| 45 |
+
Anti-injection systématique via ``html.escape``. Garde-fou
|
| 46 |
+
anti-hallucination identique au moteur narratif : chaque
|
| 47 |
+
chiffre rendu est dans le ``payload`` du levier. +18 clés
|
| 48 |
+
i18n FR/EN (``levers_*``). +40 tests dans
|
| 49 |
+
`test_sprint82_levers.py` (modèle 3, dominant 6, pareto 5,
|
| 50 |
+
complementarity 4, lexical 4, robustness 4, pipeline 3,
|
| 51 |
+
rendu 6, anti-hallucination FR+EN 3, complétude i18n 2).
|
| 52 |
+
**Verrou levé** : le rapport ne se contente plus de décrire
|
| 53 |
+
*ce qui est* — il propose une lecture compacte des
|
| 54 |
+
**dimensions où un effort éditorial pourrait porter**, sans
|
| 55 |
+
jamais imposer un verdict.
|
| 56 |
+
|
| 57 |
- **Sprint 81 — A.I.8 : robustesse synthétique projetée sur le
|
| 58 |
corpus réel (couche de calcul).** Le module
|
| 59 |
``picarones/core/robustness.py`` (Sprint 8) génère des courbes
|
|
@@ -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 |
| 81 | **Sprint 50 du plan d'évolution 2026 — A.I.8 : robustesse synthétique projetée sur corpus réel (couche calcul)**. `robustness.py` (Sprint 8) génère des courbes CER vs dégradation synthétique ; `image_quality.py` mesure le bruit/flou réels. Ce sprint projette les caractéristiques réelles sur les courbes pour estimer le déficit attendu. Nouveau module `picarones/core/robustness_projection.py` : `_interpolate_cer(levels, cer_values, target_level)` interpolation linéaire avec clip aux bornes (pas d'extrapolation hasardeuse), filtre cer None ; `_extract_quality_value(quality_dict, degradation_type, custom_mapping)` extrait depuis ImageQualityResult (mapping default noise→noise_level, blur→blur_score, etc.) ; `project_robustness_on_corpus(curves, image_qualities)` retourne `{engine: {deg_type: {n_docs, n_docs_with_data, expected_cer_mean/median, baseline_cer, deficit_vs_baseline, n_docs_above_critical, critical_threshold}}}` ; `aggregate_projection_per_engine` somme les déficits par moteur et identifie le worst_degradation_type (hypothèse d'indépendance documentée). +22 tests (interpolation 7 cas, extraction 4 cas, projection 7 cas, agrégation 4 cas). **Verrou levé** : un bench BnF lit « 30 % de vos documents ont un bruit où Tesseract perd 8 points — déficit attendu 2,4 points » — la courbe de robustesse n'est plus déconnectée du corpus réel. |
|
| 211 |
| 80 | **Sprint 49 du plan d'évolution 2026 — A.I.7 : sur-normalisation lexicale (couche calcul + table HTML)**. Le détecteur `llm_hallucination_flag` (Sprint 19) signale via un score agrégé mais ne dit pas **quoi** corriger dans le prompt. Nouveau module `picarones/core/lexical_modernization.py` : `compute_lexical_modernization(reference, hypothesis, stop_list, case_sensitive)` aligne mot-à-mot via `difflib.SequenceMatcher` et accumule par token GT `{n_total, n_modernized, rate_modernized, variants}` ; `aggregate_lexical_modernization` somme corpus-wide ; `top_modernized_tokens(data, n=20, min_total=1)` retourne les N tokens GT les plus modernisés (tri décroissant par taux, tie-break par n_total, filtre anecdotiques via min_total). Stop-list paramétrable (par défaut vide). Suppression GT → variant ∅. Nouveau module `picarones/report/lexical_modernization_render.py` : `build_lexical_modernization_html(data, labels, top_n, min_total)` tableau 4 colonnes (forme GT, variantes OCR top-3, n GT, % modernisé gradient blanc→orange). Adaptive : "" si data None ou aucun modernisé. +6 clés i18n FR/EN. +20 tests (calcul 9 cas dont systématique/préservé/partiel/multi-variants/stop-list/casse/suppression/vide, agrégation 2 cas, top 2 cas, rendu 5 cas dont anti-injection, complétude i18n). **Verrou levé** : le chercheur lit « maistre → maître modernisé dans 100 % des cas » et ajuste son prompt — info exploitable au lieu d'un score agrégé. |
|
| 212 |
| 79 | **Sprint 48 du plan d'évolution 2026 — A.I.6 : projection de coût en volume cible (couche de calcul)**. La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par unité (1 000 pages) ; payer 50 € de plus sur 50 pages est trivial, sur 5 millions ça change tout. Nouveau module `picarones/core/cost_projection.py` : `ProjectedCost(engine_key, target_pages, cost_total_eur, co2_total_g, cost_per_1k_pages_eur, co2_per_1k_pages_g, type)`, `project_cost_total/co2_total` linéaire en pages avec `None` si données insuffisantes ou target<0, `project_engine` retourne le ProjectedCost complet, `project_all_engines(engine_costs, target_pages)` projette N moteurs (ValueError si target<0, moteurs sans données conservés avec cost_total=None), `cost_gap_table(projections, baseline)` retourne `{engine: {total, delta_abs, delta_rel}}` vs baseline (KeyError si baseline inconnue, delta_rel=None si baseline=0). +17 tests (calcul 5 cas, CO₂ 2 cas, engine 2 cas, all_engines 3 cas, gap_table 4 cas, **cas réaliste BnF 80 000 pages BMS** Tesseract=3.20€/Pero=0€/Mistral=280€/GPT-4o=600€). **Verrou levé** : couche calcul prête pour câbler le panneau « Avancé » avec champ « Volume cible » qui recalcule Pareto et table coût en valeur totale projetée. UX HTML suivra. |
|
|
@@ -299,7 +300,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 299 |
## Contexte développement
|
| 300 |
|
| 301 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 302 |
-
- **Tests** :
|
| 303 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 304 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 305 |
- **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 |
+
| 82 | **Sprint 51 du plan d'évolution 2026 — A.I.9 : section « Leviers d'amélioration » (couche calcul + cards HTML)**. Le moteur narratif Sprint 19 dit *ce qui s'est passé* ; ce sprint dit *sur quelle dimension un effort éditorial pourrait porter* — purement factuel, jamais prescriptif. Nouveau module `picarones/core/levers.py` : dataclass `Lever(type, importance, payload, engines_involved)`, `LeverImportance` (HIGH=70/MEDIUM=40/LOW=10), registre via décorateur `@register_lever` (parallèle au registre narratif), `detect_levers(benchmark_data)` trie par importance décroissante. **5 détecteurs** : `dominant_recoverable_class` (≥30 % d'erreurs récupérables Sprint 77, HIGH si ≥50 %, top-3 classes), `pareto_concentration` (top-20 % des docs ≥50 % du CER cumulé sur le moteur leader, HIGH si ≥75 %), `complementarity_observation` (factuel sur `inter_engine_analysis.complementarity_gap` Sprint 35, HIGH si rel_gap ≥50 %), `lexical_modernization_observation` (top-3 tokens GT systématiquement modernisés Sprint 80, min_total=3, min_rate=0.50, HIGH si max_rate ≥90 %), `robustness_projection_observation` (déficit projeté ≥2 points de CER Sprint 81, HIGH si ≥5 points, sorted desc). Nouveau module `picarones/report/levers_render.py` : `build_levers_section_html` rend des **cards** server-side (étiquette i18n + phrase factuelle + détail compact + niveau d'importance coloré bleu/orange). Adaptive : `""` si aucun levier exploitable. Anti-injection systématique. Garde-fou anti-hallucination identique au moteur narratif : chaque chiffre rendu est dans le `payload` (test prouve la traçabilité FR+EN sur 3 leviers). +18 clés i18n FR/EN. +40 tests (modèle 3, dominant_recoverable 6, pareto 5, complementarity 4, lexical 4, robustness 4, pipeline 3, rendu 6, anti-hallucination 3, complétude i18n 2). **Verrou levé** : le rapport propose une lecture compacte des dimensions actionnables sans imposer de verdict — *« 65 % des erreurs de Tesseract sont récupérables », « 12 % des docs concentrent 78 % du CER », « top tokens modernisés : maistre, nostre, veoir »* — le chercheur juge selon son workflow. |
|
| 211 |
| 81 | **Sprint 50 du plan d'évolution 2026 — A.I.8 : robustesse synthétique projetée sur corpus réel (couche calcul)**. `robustness.py` (Sprint 8) génère des courbes CER vs dégradation synthétique ; `image_quality.py` mesure le bruit/flou réels. Ce sprint projette les caractéristiques réelles sur les courbes pour estimer le déficit attendu. Nouveau module `picarones/core/robustness_projection.py` : `_interpolate_cer(levels, cer_values, target_level)` interpolation linéaire avec clip aux bornes (pas d'extrapolation hasardeuse), filtre cer None ; `_extract_quality_value(quality_dict, degradation_type, custom_mapping)` extrait depuis ImageQualityResult (mapping default noise→noise_level, blur→blur_score, etc.) ; `project_robustness_on_corpus(curves, image_qualities)` retourne `{engine: {deg_type: {n_docs, n_docs_with_data, expected_cer_mean/median, baseline_cer, deficit_vs_baseline, n_docs_above_critical, critical_threshold}}}` ; `aggregate_projection_per_engine` somme les déficits par moteur et identifie le worst_degradation_type (hypothèse d'indépendance documentée). +22 tests (interpolation 7 cas, extraction 4 cas, projection 7 cas, agrégation 4 cas). **Verrou levé** : un bench BnF lit « 30 % de vos documents ont un bruit où Tesseract perd 8 points — déficit attendu 2,4 points » — la courbe de robustesse n'est plus déconnectée du corpus réel. |
|
| 212 |
| 80 | **Sprint 49 du plan d'évolution 2026 — A.I.7 : sur-normalisation lexicale (couche calcul + table HTML)**. Le détecteur `llm_hallucination_flag` (Sprint 19) signale via un score agrégé mais ne dit pas **quoi** corriger dans le prompt. Nouveau module `picarones/core/lexical_modernization.py` : `compute_lexical_modernization(reference, hypothesis, stop_list, case_sensitive)` aligne mot-à-mot via `difflib.SequenceMatcher` et accumule par token GT `{n_total, n_modernized, rate_modernized, variants}` ; `aggregate_lexical_modernization` somme corpus-wide ; `top_modernized_tokens(data, n=20, min_total=1)` retourne les N tokens GT les plus modernisés (tri décroissant par taux, tie-break par n_total, filtre anecdotiques via min_total). Stop-list paramétrable (par défaut vide). Suppression GT → variant ∅. Nouveau module `picarones/report/lexical_modernization_render.py` : `build_lexical_modernization_html(data, labels, top_n, min_total)` tableau 4 colonnes (forme GT, variantes OCR top-3, n GT, % modernisé gradient blanc→orange). Adaptive : "" si data None ou aucun modernisé. +6 clés i18n FR/EN. +20 tests (calcul 9 cas dont systématique/préservé/partiel/multi-variants/stop-list/casse/suppression/vide, agrégation 2 cas, top 2 cas, rendu 5 cas dont anti-injection, complétude i18n). **Verrou levé** : le chercheur lit « maistre → maître modernisé dans 100 % des cas » et ajuste son prompt — info exploitable au lieu d'un score agrégé. |
|
| 213 |
| 79 | **Sprint 48 du plan d'évolution 2026 — A.I.6 : projection de coût en volume cible (couche de calcul)**. La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par unité (1 000 pages) ; payer 50 € de plus sur 50 pages est trivial, sur 5 millions ça change tout. Nouveau module `picarones/core/cost_projection.py` : `ProjectedCost(engine_key, target_pages, cost_total_eur, co2_total_g, cost_per_1k_pages_eur, co2_per_1k_pages_g, type)`, `project_cost_total/co2_total` linéaire en pages avec `None` si données insuffisantes ou target<0, `project_engine` retourne le ProjectedCost complet, `project_all_engines(engine_costs, target_pages)` projette N moteurs (ValueError si target<0, moteurs sans données conservés avec cost_total=None), `cost_gap_table(projections, baseline)` retourne `{engine: {total, delta_abs, delta_rel}}` vs baseline (KeyError si baseline inconnue, delta_rel=None si baseline=0). +17 tests (calcul 5 cas, CO₂ 2 cas, engine 2 cas, all_engines 3 cas, gap_table 4 cas, **cas réaliste BnF 80 000 pages BMS** Tesseract=3.20€/Pero=0€/Mistral=280€/GPT-4o=600€). **Verrou levé** : couche calcul prête pour câbler le panneau « Avancé » avec champ « Volume cible » qui recalcule Pareto et table coût en valeur totale projetée. UX HTML suivra. |
|
|
|
|
| 300 |
## Contexte développement
|
| 301 |
|
| 302 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 303 |
+
- **Tests** : 2761 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 ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; Sprints 75-77 = A.I.4 livré bout-en-bout ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 — robustesse projetée sur corpus réel ; **Sprint 82 = A.I.9 — section « Leviers d'amélioration » bout-en-bout**)
|
| 304 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 305 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 306 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9).
|
| 2 |
+
|
| 3 |
+
Sprint 82 — A.I.9 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce
|
| 8 |
+
qui s'est passé** dans le benchmark : qui gagne, qui s'effondre,
|
| 9 |
+
qui est fragile. Ce sprint répond à une question
|
| 10 |
+
complémentaire : **sur quelle dimension le bénéfice attendu d'une
|
| 11 |
+
amélioration serait-il le plus visible ?**
|
| 12 |
+
|
| 13 |
+
Pas de prescription
|
| 14 |
+
-------------------
|
| 15 |
+
Picarones est un **outil de recherche**, pas un atelier de
|
| 16 |
+
production. Le module ne dit jamais *« faites X »* ni
|
| 17 |
+
*« utilisez le moteur Y »* ; il agrège des **observations
|
| 18 |
+
factuelles** déjà calculées dans d'autres modules (Sprints 75-81)
|
| 19 |
+
et les présente comme un récapitulatif compact en bas du rapport.
|
| 20 |
+
Le chercheur lit, juge et arbitre.
|
| 21 |
+
|
| 22 |
+
Exemples de leviers émis
|
| 23 |
+
------------------------
|
| 24 |
+
- *« 65 % des erreurs de Tesseract sont de classe récupérable
|
| 25 |
+
(case_error, ligature_error, abbreviation_error) — un
|
| 26 |
+
post-processing trivial absorberait une partie. »*
|
| 27 |
+
- *« 12 % de vos documents concentrent 78 % du CER total
|
| 28 |
+
(Pareto-CER). »*
|
| 29 |
+
- *« Le déficit projeté du moteur le plus fragile sur le corpus
|
| 30 |
+
réel est de 4,2 points de CER (Sprint 81). »*
|
| 31 |
+
- *« Le top-3 des tokens GT systématiquement modernisés est
|
| 32 |
+
maistre, nostre, veoir (Sprint 80). »*
|
| 33 |
+
|
| 34 |
+
Structure
|
| 35 |
+
---------
|
| 36 |
+
Module parallèle au registre narratif Sprint 19 : `Lever` est la
|
| 37 |
+
dataclass équivalente à `Fact`, `LeverImportance` reprend la
|
| 38 |
+
sémantique de `FactImportance`, `@register_lever` indexe les
|
| 39 |
+
détecteurs. Garde-fou anti-hallucination identique : chaque
|
| 40 |
+
nombre rendu doit être présent dans le `payload` du `Lever`.
|
| 41 |
+
|
| 42 |
+
Les détecteurs lisent **uniquement** des structures déjà
|
| 43 |
+
construites par le pipeline du benchmark — ils ne calculent rien
|
| 44 |
+
de nouveau, ils synthétisent. C'est pourquoi le module est
|
| 45 |
+
résolument optionnel : si un benchmark n'expose pas
|
| 46 |
+
`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`,
|
| 47 |
+
`lexical_modernization` ou `robustness_projection`, le détecteur
|
| 48 |
+
correspondant retourne tout simplement `[]`.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
from __future__ import annotations
|
| 52 |
+
|
| 53 |
+
import logging
|
| 54 |
+
import threading
|
| 55 |
+
from dataclasses import dataclass
|
| 56 |
+
from enum import Enum
|
| 57 |
+
from typing import Callable
|
| 58 |
+
|
| 59 |
+
logger = logging.getLogger(__name__)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
+
# Modèle
|
| 64 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class LeverType(str, Enum):
|
| 68 |
+
"""Types de leviers détectés."""
|
| 69 |
+
|
| 70 |
+
DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class"
|
| 71 |
+
"""Une part importante des erreurs d'un moteur est dans des classes
|
| 72 |
+
catégorisées « récupérables » (Sprint 77)."""
|
| 73 |
+
|
| 74 |
+
PARETO_CONCENTRATION = "pareto_concentration"
|
| 75 |
+
"""Une fraction minoritaire de documents concentre une fraction
|
| 76 |
+
majoritaire du CER total — l'inspection ciblée est rentable."""
|
| 77 |
+
|
| 78 |
+
COMPLEMENTARITY_OBSERVATION = "complementarity_observation"
|
| 79 |
+
"""Le `complementarity_gap` (Sprint 35) entre l'oracle et le
|
| 80 |
+
meilleur moteur seul est non négligeable — observation factuelle,
|
| 81 |
+
aucune recommandation d'ensemble."""
|
| 82 |
+
|
| 83 |
+
LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation"
|
| 84 |
+
"""Top-N des tokens GT systématiquement modernisés (Sprint 80)."""
|
| 85 |
+
|
| 86 |
+
ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation"
|
| 87 |
+
"""Déficit projeté global le plus important pour un moteur sur
|
| 88 |
+
le corpus réel (Sprint 81)."""
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class LeverImportance(int, Enum):
|
| 92 |
+
"""Importance éditoriale d'un levier."""
|
| 93 |
+
|
| 94 |
+
HIGH = 70
|
| 95 |
+
MEDIUM = 40
|
| 96 |
+
LOW = 10
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass
|
| 100 |
+
class Lever:
|
| 101 |
+
"""Observation factuelle synthétisable en encart « Leviers ».
|
| 102 |
+
|
| 103 |
+
Attributes
|
| 104 |
+
----------
|
| 105 |
+
type:
|
| 106 |
+
Le type de levier (voir `LeverType`).
|
| 107 |
+
importance:
|
| 108 |
+
Score qui décide l'ordre d'affichage.
|
| 109 |
+
payload:
|
| 110 |
+
Données brutes — **tout chiffre rendu dans le HTML doit
|
| 111 |
+
provenir d'ici**, jamais d'un calcul du renderer.
|
| 112 |
+
engines_involved:
|
| 113 |
+
Noms des moteurs concernés (peut être vide pour un levier
|
| 114 |
+
corpus-wide).
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
type: LeverType
|
| 118 |
+
importance: LeverImportance
|
| 119 |
+
payload: dict
|
| 120 |
+
engines_involved: tuple[str, ...] = ()
|
| 121 |
+
|
| 122 |
+
def as_dict(self) -> dict:
|
| 123 |
+
return {
|
| 124 |
+
"type": self.type.value,
|
| 125 |
+
"importance": int(self.importance),
|
| 126 |
+
"payload": self.payload,
|
| 127 |
+
"engines_involved": list(self.engines_involved),
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 132 |
+
# Registre
|
| 133 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
LeverDetectorFn = Callable[[dict], list[Lever]]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@dataclass(frozen=True)
|
| 140 |
+
class LeverDetectorEntry:
|
| 141 |
+
lever_type: LeverType
|
| 142 |
+
fn: LeverDetectorFn
|
| 143 |
+
priority: int
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {}
|
| 147 |
+
_LEVER_REGISTRY_LOCK = threading.Lock()
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def register_lever(
|
| 151 |
+
lever_type: LeverType,
|
| 152 |
+
*,
|
| 153 |
+
priority: int,
|
| 154 |
+
) -> Callable[[LeverDetectorFn], LeverDetectorFn]:
|
| 155 |
+
"""Décorateur : enregistre un détecteur de levier.
|
| 156 |
+
|
| 157 |
+
Une seule fonction par type — réenregistrer lève `ValueError`.
|
| 158 |
+
"""
|
| 159 |
+
def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn:
|
| 160 |
+
with _LEVER_REGISTRY_LOCK:
|
| 161 |
+
if lever_type in _LEVER_REGISTRY:
|
| 162 |
+
raise ValueError(
|
| 163 |
+
f"Détecteur déjà enregistré pour {lever_type.value!r} : "
|
| 164 |
+
f"{_LEVER_REGISTRY[lever_type].fn.__name__}."
|
| 165 |
+
)
|
| 166 |
+
_LEVER_REGISTRY[lever_type] = LeverDetectorEntry(
|
| 167 |
+
lever_type=lever_type, fn=fn, priority=int(priority),
|
| 168 |
+
)
|
| 169 |
+
return fn
|
| 170 |
+
return _decorator
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def unregister_lever(lever_type: LeverType) -> None:
|
| 174 |
+
with _LEVER_REGISTRY_LOCK:
|
| 175 |
+
_LEVER_REGISTRY.pop(lever_type, None)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def iter_lever_detectors() -> list[LeverDetectorEntry]:
|
| 179 |
+
with _LEVER_REGISTRY_LOCK:
|
| 180 |
+
entries = list(_LEVER_REGISTRY.values())
|
| 181 |
+
entries.sort(key=lambda e: e.priority)
|
| 182 |
+
return entries
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def detect_levers(benchmark_data: dict) -> list[Lever]:
|
| 186 |
+
"""Applique tous les détecteurs enregistrés et trie par importance
|
| 187 |
+
décroissante puis priorité d'enregistrement croissante."""
|
| 188 |
+
levers: list[Lever] = []
|
| 189 |
+
for entry in iter_lever_detectors():
|
| 190 |
+
try:
|
| 191 |
+
result = entry.fn(benchmark_data)
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.warning(
|
| 194 |
+
"[levers.detector.%s] fonctionnalité dégradée : %s",
|
| 195 |
+
entry.lever_type.value, e,
|
| 196 |
+
)
|
| 197 |
+
continue
|
| 198 |
+
if result:
|
| 199 |
+
levers.extend(result)
|
| 200 |
+
# Tri stable : importance décroissante d'abord
|
| 201 |
+
levers.sort(key=lambda lv: -int(lv.importance))
|
| 202 |
+
return levers
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 206 |
+
# Détecteurs
|
| 207 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py).
|
| 211 |
+
# Volontairement dupliquée ici pour ne pas introduire d'import
|
| 212 |
+
# circulaire — la sémantique est gelée.
|
| 213 |
+
_RECOVERABILITY: dict[str, str] = {
|
| 214 |
+
"case_error": "recoverable",
|
| 215 |
+
"ligature_error": "recoverable",
|
| 216 |
+
"abbreviation_error": "recoverable",
|
| 217 |
+
"diacritic_error": "difficult",
|
| 218 |
+
"visual_confusion": "difficult",
|
| 219 |
+
"hapax": "difficult",
|
| 220 |
+
"lacuna": "irrecoverable",
|
| 221 |
+
"oov_character": "irrecoverable",
|
| 222 |
+
"segmentation_error": "irrecoverable",
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10)
|
| 227 |
+
def detect_dominant_recoverable_class(
|
| 228 |
+
benchmark_data: dict,
|
| 229 |
+
*,
|
| 230 |
+
threshold: float = 0.30,
|
| 231 |
+
) -> list[Lever]:
|
| 232 |
+
"""Émet un levier si ≥ `threshold` des erreurs d'un moteur sont
|
| 233 |
+
classifiées récupérables (catégorisation Sprint 77).
|
| 234 |
+
|
| 235 |
+
Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` —
|
| 236 |
+
structure produite par le runner historique. Si absent, retourne
|
| 237 |
+
[].
|
| 238 |
+
"""
|
| 239 |
+
engines = benchmark_data.get("engines") or []
|
| 240 |
+
out: list[Lever] = []
|
| 241 |
+
for engine in engines:
|
| 242 |
+
taxonomy = engine.get("aggregated_taxonomy")
|
| 243 |
+
if not taxonomy:
|
| 244 |
+
continue
|
| 245 |
+
# `taxonomy` peut être {class_name: int} ou un dict avec une
|
| 246 |
+
# sous-clé "counts" — on accepte les deux conventions.
|
| 247 |
+
counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy
|
| 248 |
+
if not isinstance(counts, dict) or not counts:
|
| 249 |
+
continue
|
| 250 |
+
try:
|
| 251 |
+
int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))}
|
| 252 |
+
except (TypeError, ValueError):
|
| 253 |
+
continue
|
| 254 |
+
total = sum(int_counts.values())
|
| 255 |
+
if total <= 0:
|
| 256 |
+
continue
|
| 257 |
+
recoverable_total = sum(
|
| 258 |
+
v for k, v in int_counts.items()
|
| 259 |
+
if _RECOVERABILITY.get(k) == "recoverable"
|
| 260 |
+
)
|
| 261 |
+
share = recoverable_total / total
|
| 262 |
+
if share < threshold:
|
| 263 |
+
continue
|
| 264 |
+
# Classes récupérables non vides triées par count décroissant
|
| 265 |
+
breakdown = sorted(
|
| 266 |
+
(
|
| 267 |
+
(k, v) for k, v in int_counts.items()
|
| 268 |
+
if _RECOVERABILITY.get(k) == "recoverable" and v > 0
|
| 269 |
+
),
|
| 270 |
+
key=lambda kv: -kv[1],
|
| 271 |
+
)
|
| 272 |
+
importance = (
|
| 273 |
+
LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM
|
| 274 |
+
)
|
| 275 |
+
out.append(Lever(
|
| 276 |
+
type=LeverType.DOMINANT_RECOVERABLE_CLASS,
|
| 277 |
+
importance=importance,
|
| 278 |
+
payload={
|
| 279 |
+
"engine": engine.get("name") or "?",
|
| 280 |
+
"share_recoverable": share,
|
| 281 |
+
"share_recoverable_pct": round(share * 100, 1),
|
| 282 |
+
"n_recoverable": recoverable_total,
|
| 283 |
+
"n_total_errors": total,
|
| 284 |
+
"top_classes": [
|
| 285 |
+
{"class": k, "count": v} for k, v in breakdown[:3]
|
| 286 |
+
],
|
| 287 |
+
},
|
| 288 |
+
engines_involved=(engine.get("name") or "?",),
|
| 289 |
+
))
|
| 290 |
+
return out
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@register_lever(LeverType.PARETO_CONCENTRATION, priority=20)
|
| 294 |
+
def detect_pareto_concentration(
|
| 295 |
+
benchmark_data: dict,
|
| 296 |
+
*,
|
| 297 |
+
top_share: float = 0.20,
|
| 298 |
+
cer_share_threshold: float = 0.50,
|
| 299 |
+
) -> list[Lever]:
|
| 300 |
+
"""Émet un levier si une fraction minoritaire de documents
|
| 301 |
+
(`top_share`) concentre plus de `cer_share_threshold` du CER
|
| 302 |
+
total cumulé sur le moteur leader.
|
| 303 |
+
|
| 304 |
+
Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de
|
| 305 |
+
reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`.
|
| 306 |
+
Si rien d'exploitable, retourne [].
|
| 307 |
+
"""
|
| 308 |
+
ranking = benchmark_data.get("ranking") or []
|
| 309 |
+
if not ranking:
|
| 310 |
+
return []
|
| 311 |
+
leader = ranking[0]
|
| 312 |
+
leader_name = leader.get("engine")
|
| 313 |
+
if not leader_name:
|
| 314 |
+
return []
|
| 315 |
+
|
| 316 |
+
per_doc_cer: list[float] = []
|
| 317 |
+
# Voie 1 : structure plate "per_doc_cer"
|
| 318 |
+
flat = benchmark_data.get("per_doc_cer") or {}
|
| 319 |
+
if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list):
|
| 320 |
+
per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))]
|
| 321 |
+
else:
|
| 322 |
+
# Voie 2 : engine.per_doc liste de dicts {cer: float}
|
| 323 |
+
for engine in benchmark_data.get("engines") or []:
|
| 324 |
+
if engine.get("name") != leader_name:
|
| 325 |
+
continue
|
| 326 |
+
per_doc = engine.get("per_doc") or []
|
| 327 |
+
for entry in per_doc:
|
| 328 |
+
if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)):
|
| 329 |
+
per_doc_cer.append(float(entry["cer"]))
|
| 330 |
+
break
|
| 331 |
+
|
| 332 |
+
if not per_doc_cer:
|
| 333 |
+
return []
|
| 334 |
+
total_cer = sum(per_doc_cer)
|
| 335 |
+
if total_cer <= 0:
|
| 336 |
+
return []
|
| 337 |
+
|
| 338 |
+
sorted_cer = sorted(per_doc_cer, reverse=True)
|
| 339 |
+
n = len(sorted_cer)
|
| 340 |
+
n_top = max(1, int(round(top_share * n)))
|
| 341 |
+
top_cer_sum = sum(sorted_cer[:n_top])
|
| 342 |
+
share_of_total = top_cer_sum / total_cer
|
| 343 |
+
if share_of_total < cer_share_threshold:
|
| 344 |
+
return []
|
| 345 |
+
importance = (
|
| 346 |
+
LeverImportance.HIGH if share_of_total >= 0.75
|
| 347 |
+
else LeverImportance.MEDIUM
|
| 348 |
+
)
|
| 349 |
+
return [Lever(
|
| 350 |
+
type=LeverType.PARETO_CONCENTRATION,
|
| 351 |
+
importance=importance,
|
| 352 |
+
payload={
|
| 353 |
+
"engine": leader_name,
|
| 354 |
+
"n_docs": n,
|
| 355 |
+
"n_docs_top": n_top,
|
| 356 |
+
"top_share_pct": round((n_top / n) * 100, 1),
|
| 357 |
+
"cer_share_of_total": share_of_total,
|
| 358 |
+
"cer_share_pct": round(share_of_total * 100, 1),
|
| 359 |
+
},
|
| 360 |
+
engines_involved=(leader_name,),
|
| 361 |
+
)]
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30)
|
| 365 |
+
def detect_complementarity_observation(
|
| 366 |
+
benchmark_data: dict,
|
| 367 |
+
*,
|
| 368 |
+
min_relative_gap: float = 0.20,
|
| 369 |
+
) -> list[Lever]:
|
| 370 |
+
"""Reformule factuellement le `complementarity_gap` (Sprint 35).
|
| 371 |
+
|
| 372 |
+
Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne
|
| 373 |
+
déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune
|
| 374 |
+
recommandation d'ensemble** — le levier dit factuellement
|
| 375 |
+
« X points séparent l'oracle du meilleur moteur », c'est tout.
|
| 376 |
+
"""
|
| 377 |
+
inter = benchmark_data.get("inter_engine_analysis") or {}
|
| 378 |
+
cgap = inter.get("complementarity_gap") or {}
|
| 379 |
+
relative_gap = cgap.get("relative_gap")
|
| 380 |
+
absolute_gap = cgap.get("absolute_gap")
|
| 381 |
+
if relative_gap is None or absolute_gap is None:
|
| 382 |
+
return []
|
| 383 |
+
try:
|
| 384 |
+
rg = float(relative_gap)
|
| 385 |
+
ag = float(absolute_gap)
|
| 386 |
+
except (TypeError, ValueError):
|
| 387 |
+
return []
|
| 388 |
+
if rg < min_relative_gap:
|
| 389 |
+
return []
|
| 390 |
+
importance = (
|
| 391 |
+
LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM
|
| 392 |
+
)
|
| 393 |
+
payload: dict = {
|
| 394 |
+
"absolute_gap": ag,
|
| 395 |
+
"absolute_gap_pct": round(ag * 100, 1),
|
| 396 |
+
"relative_gap": rg,
|
| 397 |
+
"relative_gap_pct": round(rg * 100, 1),
|
| 398 |
+
}
|
| 399 |
+
best_engine = cgap.get("best_engine") or inter.get("best_engine")
|
| 400 |
+
best_recall = cgap.get("best_recall") or inter.get("best_engine_recall")
|
| 401 |
+
oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall")
|
| 402 |
+
engines_involved: tuple[str, ...] = ()
|
| 403 |
+
if best_engine:
|
| 404 |
+
payload["best_engine"] = str(best_engine)
|
| 405 |
+
engines_involved = (str(best_engine),)
|
| 406 |
+
if isinstance(best_recall, (int, float)):
|
| 407 |
+
payload["best_recall"] = float(best_recall)
|
| 408 |
+
if isinstance(oracle_recall, (int, float)):
|
| 409 |
+
payload["oracle_recall"] = float(oracle_recall)
|
| 410 |
+
return [Lever(
|
| 411 |
+
type=LeverType.COMPLEMENTARITY_OBSERVATION,
|
| 412 |
+
importance=importance,
|
| 413 |
+
payload=payload,
|
| 414 |
+
engines_involved=engines_involved,
|
| 415 |
+
)]
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40)
|
| 419 |
+
def detect_lexical_modernization_observation(
|
| 420 |
+
benchmark_data: dict,
|
| 421 |
+
*,
|
| 422 |
+
top_n: int = 3,
|
| 423 |
+
min_total: int = 3,
|
| 424 |
+
min_rate: float = 0.50,
|
| 425 |
+
) -> list[Lever]:
|
| 426 |
+
"""Pour chaque moteur disposant de `lexical_modernization`,
|
| 427 |
+
émet un levier listant les `top_n` tokens GT les plus modernisés.
|
| 428 |
+
|
| 429 |
+
Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui
|
| 430 |
+
suit la forme produite par `compute_lexical_modernization` du
|
| 431 |
+
Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`).
|
| 432 |
+
"""
|
| 433 |
+
out: list[Lever] = []
|
| 434 |
+
for engine in benchmark_data.get("engines") or []:
|
| 435 |
+
data = engine.get("lexical_modernization")
|
| 436 |
+
if not isinstance(data, dict):
|
| 437 |
+
continue
|
| 438 |
+
tokens = data.get("tokens") or {}
|
| 439 |
+
if not isinstance(tokens, dict) or not tokens:
|
| 440 |
+
continue
|
| 441 |
+
candidates: list[tuple[str, dict]] = []
|
| 442 |
+
for gt_token, slot in tokens.items():
|
| 443 |
+
if not isinstance(slot, dict):
|
| 444 |
+
continue
|
| 445 |
+
n_total = slot.get("n_total")
|
| 446 |
+
rate = slot.get("rate_modernized")
|
| 447 |
+
if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)):
|
| 448 |
+
continue
|
| 449 |
+
if int(n_total) < min_total:
|
| 450 |
+
continue
|
| 451 |
+
if float(rate) < min_rate:
|
| 452 |
+
continue
|
| 453 |
+
candidates.append((gt_token, dict(slot)))
|
| 454 |
+
if not candidates:
|
| 455 |
+
continue
|
| 456 |
+
candidates.sort(
|
| 457 |
+
key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)),
|
| 458 |
+
-int(kv[1].get("n_total", 0)),
|
| 459 |
+
kv[0]),
|
| 460 |
+
)
|
| 461 |
+
top = candidates[:top_n]
|
| 462 |
+
engine_name = engine.get("name") or "?"
|
| 463 |
+
max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top)
|
| 464 |
+
importance = (
|
| 465 |
+
LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM
|
| 466 |
+
)
|
| 467 |
+
out.append(Lever(
|
| 468 |
+
type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION,
|
| 469 |
+
importance=importance,
|
| 470 |
+
payload={
|
| 471 |
+
"engine": engine_name,
|
| 472 |
+
"top_tokens": [
|
| 473 |
+
{
|
| 474 |
+
"gt_token": gt,
|
| 475 |
+
"n_total": int(slot.get("n_total", 0)),
|
| 476 |
+
"rate_modernized": float(slot.get("rate_modernized", 0.0)),
|
| 477 |
+
"rate_modernized_pct": round(
|
| 478 |
+
float(slot.get("rate_modernized", 0.0)) * 100, 1,
|
| 479 |
+
),
|
| 480 |
+
}
|
| 481 |
+
for gt, slot in top
|
| 482 |
+
],
|
| 483 |
+
},
|
| 484 |
+
engines_involved=(engine_name,),
|
| 485 |
+
))
|
| 486 |
+
return out
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50)
|
| 490 |
+
def detect_robustness_projection_observation(
|
| 491 |
+
benchmark_data: dict,
|
| 492 |
+
*,
|
| 493 |
+
min_total_deficit: float = 0.02,
|
| 494 |
+
) -> list[Lever]:
|
| 495 |
+
"""Lit l'agrégation par moteur de la projection de robustesse
|
| 496 |
+
(Sprint 81). Émet le levier pour le moteur dont
|
| 497 |
+
`total_expected_deficit` est ≥ `min_total_deficit` (par défaut
|
| 498 |
+
2 points de CER).
|
| 499 |
+
|
| 500 |
+
Lit `benchmark_data["robustness_projection_aggregated"]` —
|
| 501 |
+
structure produite par `aggregate_projection_per_engine`.
|
| 502 |
+
"""
|
| 503 |
+
agg = benchmark_data.get("robustness_projection_aggregated") or {}
|
| 504 |
+
if not isinstance(agg, dict) or not agg:
|
| 505 |
+
return []
|
| 506 |
+
out: list[Lever] = []
|
| 507 |
+
for engine_name, info in agg.items():
|
| 508 |
+
if not isinstance(info, dict):
|
| 509 |
+
continue
|
| 510 |
+
total_deficit = info.get("total_expected_deficit")
|
| 511 |
+
worst_type = info.get("worst_degradation_type")
|
| 512 |
+
worst_deficit = info.get("worst_degradation_deficit")
|
| 513 |
+
if not isinstance(total_deficit, (int, float)):
|
| 514 |
+
continue
|
| 515 |
+
if float(total_deficit) < min_total_deficit:
|
| 516 |
+
continue
|
| 517 |
+
importance = (
|
| 518 |
+
LeverImportance.HIGH if float(total_deficit) >= 0.05
|
| 519 |
+
else LeverImportance.MEDIUM
|
| 520 |
+
)
|
| 521 |
+
payload: dict = {
|
| 522 |
+
"engine": engine_name,
|
| 523 |
+
"total_expected_deficit": float(total_deficit),
|
| 524 |
+
"total_expected_deficit_pct": round(float(total_deficit) * 100, 1),
|
| 525 |
+
"n_degradation_types": int(info.get("n_degradation_types") or 0),
|
| 526 |
+
}
|
| 527 |
+
if isinstance(worst_type, str):
|
| 528 |
+
payload["worst_degradation_type"] = worst_type
|
| 529 |
+
if isinstance(worst_deficit, (int, float)):
|
| 530 |
+
payload["worst_degradation_deficit"] = float(worst_deficit)
|
| 531 |
+
payload["worst_degradation_deficit_pct"] = round(
|
| 532 |
+
float(worst_deficit) * 100, 1,
|
| 533 |
+
)
|
| 534 |
+
out.append(Lever(
|
| 535 |
+
type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION,
|
| 536 |
+
importance=importance,
|
| 537 |
+
payload=payload,
|
| 538 |
+
engines_involved=(engine_name,),
|
| 539 |
+
))
|
| 540 |
+
# Tri par déficit décroissant pour stabilité d'affichage.
|
| 541 |
+
out.sort(
|
| 542 |
+
key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0),
|
| 543 |
+
)
|
| 544 |
+
return out
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
__all__ = [
|
| 548 |
+
"Lever",
|
| 549 |
+
"LeverImportance",
|
| 550 |
+
"LeverType",
|
| 551 |
+
"LeverDetectorEntry",
|
| 552 |
+
"register_lever",
|
| 553 |
+
"unregister_lever",
|
| 554 |
+
"iter_lever_detectors",
|
| 555 |
+
"detect_levers",
|
| 556 |
+
"detect_dominant_recoverable_class",
|
| 557 |
+
"detect_pareto_concentration",
|
| 558 |
+
"detect_complementarity_observation",
|
| 559 |
+
"detect_lexical_modernization_observation",
|
| 560 |
+
"detect_robustness_projection_observation",
|
| 561 |
+
]
|
|
@@ -262,5 +262,23 @@
|
|
| 262 |
"lexmod_gt_label": "Historical GT form",
|
| 263 |
"lexmod_hyp_label": "OCR variants",
|
| 264 |
"lexmod_n_label": "n GT",
|
| 265 |
-
"lexmod_rate_label": "% modernized"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
}
|
|
|
|
| 262 |
"lexmod_gt_label": "Historical GT form",
|
| 263 |
"lexmod_hyp_label": "OCR variants",
|
| 264 |
"lexmod_n_label": "n GT",
|
| 265 |
+
"lexmod_rate_label": "% modernized",
|
| 266 |
+
"levers_title": "Improvement leverages",
|
| 267 |
+
"levers_note": "Factual observations synthesized from the analysis modules. No prescription imposed — the researcher decides what is actionable for their workflow.",
|
| 268 |
+
"levers_top_classes": "Main classes:",
|
| 269 |
+
"levers_importance_high": "Important",
|
| 270 |
+
"levers_importance_medium": "Notable",
|
| 271 |
+
"levers_importance_low": "Minor",
|
| 272 |
+
"levers_label_dominant_recoverable_class": "Mostly recoverable error classes",
|
| 273 |
+
"levers_label_pareto_concentration": "CER Pareto concentration",
|
| 274 |
+
"levers_label_complementarity_observation": "Inter-engine complementarity",
|
| 275 |
+
"levers_label_lexical_modernization_observation": "Systematic lexical modernization",
|
| 276 |
+
"levers_label_robustness_projection_observation": "Projected deficit on the real corpus",
|
| 277 |
+
"levers_dominant_recoverable_phrase": "{pct}% of {engine}'s errors ({n_recov}/{n_total}) are classified as recoverable (case_error, ligature_error, abbreviation_error).",
|
| 278 |
+
"levers_pareto_phrase": "On {engine}, {n_top} documents ({top_pct}% of the corpus) concentrate {cer_pct}% of the cumulative CER (out of {n_total} documents).",
|
| 279 |
+
"levers_complementarity_phrase": "The bag-of-words oracle achieves a recall {abs_pct} points higher (+{rel_pct}% relative) than the best single engine.",
|
| 280 |
+
"levers_complementarity_phrase_with_engine": "The bag-of-words oracle achieves a recall {abs_pct} points higher (+{rel_pct}% relative) than the best single engine ({best_engine}).",
|
| 281 |
+
"levers_lexical_phrase": "Top GT tokens systematically modernized by {engine}: {items}.",
|
| 282 |
+
"levers_robustness_phrase": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations.",
|
| 283 |
+
"levers_robustness_phrase_with_worst": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations — worst degradation: {worst_type} ({worst_pct} points)."
|
| 284 |
}
|
|
@@ -262,5 +262,23 @@
|
|
| 262 |
"lexmod_gt_label": "Forme historique GT",
|
| 263 |
"lexmod_hyp_label": "Variantes OCR",
|
| 264 |
"lexmod_n_label": "n GT",
|
| 265 |
-
"lexmod_rate_label": "% modernisé"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
}
|
|
|
|
| 262 |
"lexmod_gt_label": "Forme historique GT",
|
| 263 |
"lexmod_hyp_label": "Variantes OCR",
|
| 264 |
"lexmod_n_label": "n GT",
|
| 265 |
+
"lexmod_rate_label": "% modernisé",
|
| 266 |
+
"levers_title": "Leviers d'amélioration",
|
| 267 |
+
"levers_note": "Observations factuelles synthétisées depuis les modules d'analyse. Aucune recommandation imposée — c'est au chercheur de juger ce qui est exploitable selon son workflow.",
|
| 268 |
+
"levers_top_classes": "Principales classes :",
|
| 269 |
+
"levers_importance_high": "Important",
|
| 270 |
+
"levers_importance_medium": "À noter",
|
| 271 |
+
"levers_importance_low": "Mineur",
|
| 272 |
+
"levers_label_dominant_recoverable_class": "Erreurs majoritairement récupérables",
|
| 273 |
+
"levers_label_pareto_concentration": "Concentration Pareto du CER",
|
| 274 |
+
"levers_label_complementarity_observation": "Complémentarité inter-moteurs",
|
| 275 |
+
"levers_label_lexical_modernization_observation": "Modernisation lexicale systématique",
|
| 276 |
+
"levers_label_robustness_projection_observation": "Déficit projeté sur le corpus réel",
|
| 277 |
+
"levers_dominant_recoverable_phrase": "{pct}% des erreurs de {engine} ({n_recov}/{n_total}) sont classifiées récupérables (case_error, ligature_error, abbreviation_error).",
|
| 278 |
+
"levers_pareto_phrase": "Sur {engine}, {n_top} documents ({top_pct}% du corpus) concentrent {cer_pct}% du CER cumulé (sur {n_total} documents au total).",
|
| 279 |
+
"levers_complementarity_phrase": "L'oracle bag-of-words atteint un rappel supérieur de {abs_pct} points (+{rel_pct}% relatif) à celui du meilleur moteur seul.",
|
| 280 |
+
"levers_complementarity_phrase_with_engine": "L'oracle bag-of-words atteint un rappel supérieur de {abs_pct} points (+{rel_pct}% relatif) à celui du meilleur moteur seul ({best_engine}).",
|
| 281 |
+
"levers_lexical_phrase": "Top tokens GT systématiquement modernisés par {engine} : {items}.",
|
| 282 |
+
"levers_robustness_phrase": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations.",
|
| 283 |
+
"levers_robustness_phrase_with_worst": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations — pire dégradation : {worst_type} ({worst_pct} points)."
|
| 284 |
}
|
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML de la section « Leviers d'amélioration » — Sprint 82.
|
| 2 |
+
|
| 3 |
+
A.I.9 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Suite directe ``picarones/core/levers.py``. Pattern identique aux
|
| 6 |
+
autres rendus (Sprints 41/43/62/67/72/74/75/76/77/80) : **server-
|
| 7 |
+
side**, pas de JavaScript, anti-injection systématique.
|
| 8 |
+
|
| 9 |
+
Vue
|
| 10 |
+
---
|
| 11 |
+
Une section composée de **cards** : une par levier, triée par
|
| 12 |
+
importance décroissante. Chaque card affiche :
|
| 13 |
+
|
| 14 |
+
- une *étiquette* (libellé i18n du type de levier) ;
|
| 15 |
+
- une *phrase factuelle* qui réutilise les chiffres du
|
| 16 |
+
``payload`` (anti-hallucination : aucun chiffre n'est calculé
|
| 17 |
+
dans le rendu) ;
|
| 18 |
+
- éventuellement un **détail compact** (top-N tokens, top-3
|
| 19 |
+
classes, etc.) ;
|
| 20 |
+
- une *note* d'importance : HIGH / MEDIUM / LOW.
|
| 21 |
+
|
| 22 |
+
Aucune classification automatique « bon » / « mauvais » et aucune
|
| 23 |
+
recommandation : la phrase est purement descriptive.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from html import escape as _e
|
| 29 |
+
from typing import Iterable, Optional
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _lever_label(lever_type: str, labels: dict[str, str]) -> str:
|
| 33 |
+
return labels.get(f"levers_label_{lever_type}", lever_type)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _format_dominant_recoverable(payload: dict, labels: dict[str, str]) -> str:
|
| 37 |
+
engine = _e(str(payload.get("engine", "?")))
|
| 38 |
+
pct = payload.get("share_recoverable_pct")
|
| 39 |
+
n_recov = payload.get("n_recoverable")
|
| 40 |
+
n_total = payload.get("n_total_errors")
|
| 41 |
+
template = labels.get(
|
| 42 |
+
"levers_dominant_recoverable_phrase",
|
| 43 |
+
"{pct}% des erreurs de {engine} ({n_recov}/{n_total}) sont "
|
| 44 |
+
"classifiées récupérables (case_error, ligature_error, "
|
| 45 |
+
"abbreviation_error).",
|
| 46 |
+
)
|
| 47 |
+
sentence = template.format(
|
| 48 |
+
engine=engine,
|
| 49 |
+
pct=pct,
|
| 50 |
+
n_recov=n_recov,
|
| 51 |
+
n_total=n_total,
|
| 52 |
+
)
|
| 53 |
+
top_classes = payload.get("top_classes") or []
|
| 54 |
+
if top_classes:
|
| 55 |
+
breakdown = ", ".join(
|
| 56 |
+
f"{_e(str(c.get('class', '?')))} ({c.get('count', 0)})"
|
| 57 |
+
for c in top_classes
|
| 58 |
+
)
|
| 59 |
+
detail_label = labels.get("levers_top_classes", "Principales :")
|
| 60 |
+
sentence += (
|
| 61 |
+
f' <span style="opacity:.8">— {_e(detail_label)} '
|
| 62 |
+
f'{breakdown}</span>'
|
| 63 |
+
)
|
| 64 |
+
return sentence
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _format_pareto_concentration(payload: dict, labels: dict[str, str]) -> str:
|
| 68 |
+
engine = _e(str(payload.get("engine", "?")))
|
| 69 |
+
n_top = payload.get("n_docs_top")
|
| 70 |
+
n_total = payload.get("n_docs")
|
| 71 |
+
top_pct = payload.get("top_share_pct")
|
| 72 |
+
cer_pct = payload.get("cer_share_pct")
|
| 73 |
+
template = labels.get(
|
| 74 |
+
"levers_pareto_phrase",
|
| 75 |
+
"Sur {engine}, {n_top} documents ({top_pct}% du corpus) "
|
| 76 |
+
"concentrent {cer_pct}% du CER cumulé "
|
| 77 |
+
"(sur {n_total} documents au total).",
|
| 78 |
+
)
|
| 79 |
+
return template.format(
|
| 80 |
+
engine=engine,
|
| 81 |
+
n_top=n_top,
|
| 82 |
+
n_total=n_total,
|
| 83 |
+
top_pct=top_pct,
|
| 84 |
+
cer_pct=cer_pct,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _format_complementarity(payload: dict, labels: dict[str, str]) -> str:
|
| 89 |
+
abs_pct = payload.get("absolute_gap_pct")
|
| 90 |
+
rel_pct = payload.get("relative_gap_pct")
|
| 91 |
+
best_engine = payload.get("best_engine")
|
| 92 |
+
if best_engine:
|
| 93 |
+
template = labels.get(
|
| 94 |
+
"levers_complementarity_phrase_with_engine",
|
| 95 |
+
"L'oracle bag-of-words atteint un rappel supérieur de "
|
| 96 |
+
"{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
|
| 97 |
+
"moteur seul ({best_engine}).",
|
| 98 |
+
)
|
| 99 |
+
return template.format(
|
| 100 |
+
abs_pct=abs_pct,
|
| 101 |
+
rel_pct=rel_pct,
|
| 102 |
+
best_engine=_e(str(best_engine)),
|
| 103 |
+
)
|
| 104 |
+
template = labels.get(
|
| 105 |
+
"levers_complementarity_phrase",
|
| 106 |
+
"L'oracle bag-of-words atteint un rappel supérieur de "
|
| 107 |
+
"{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
|
| 108 |
+
"moteur seul.",
|
| 109 |
+
)
|
| 110 |
+
return template.format(abs_pct=abs_pct, rel_pct=rel_pct)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _format_lexical_modernization(payload: dict, labels: dict[str, str]) -> str:
|
| 114 |
+
engine = _e(str(payload.get("engine", "?")))
|
| 115 |
+
top_tokens = payload.get("top_tokens") or []
|
| 116 |
+
if not top_tokens:
|
| 117 |
+
return ""
|
| 118 |
+
items = ", ".join(
|
| 119 |
+
f"{_e(str(t.get('gt_token', '?')))} "
|
| 120 |
+
f"({t.get('rate_modernized_pct', 0)}%, "
|
| 121 |
+
f"n={t.get('n_total', 0)})"
|
| 122 |
+
for t in top_tokens
|
| 123 |
+
)
|
| 124 |
+
template = labels.get(
|
| 125 |
+
"levers_lexical_phrase",
|
| 126 |
+
"Top tokens GT systématiquement modernisés par {engine} : {items}.",
|
| 127 |
+
)
|
| 128 |
+
return template.format(engine=engine, items=items)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _format_robustness_projection(payload: dict, labels: dict[str, str]) -> str:
|
| 132 |
+
engine = _e(str(payload.get("engine", "?")))
|
| 133 |
+
deficit_pct = payload.get("total_expected_deficit_pct")
|
| 134 |
+
n_types = payload.get("n_degradation_types", 0)
|
| 135 |
+
worst_type = payload.get("worst_degradation_type")
|
| 136 |
+
worst_pct = payload.get("worst_degradation_deficit_pct")
|
| 137 |
+
if worst_type and worst_pct is not None:
|
| 138 |
+
template = labels.get(
|
| 139 |
+
"levers_robustness_phrase_with_worst",
|
| 140 |
+
"Déficit projeté de {engine} sur le corpus réel : "
|
| 141 |
+
"{deficit_pct} points de CER cumulés sur {n_types} "
|
| 142 |
+
"dégradations — pire dégradation : {worst_type} "
|
| 143 |
+
"({worst_pct} points).",
|
| 144 |
+
)
|
| 145 |
+
return template.format(
|
| 146 |
+
engine=engine,
|
| 147 |
+
deficit_pct=deficit_pct,
|
| 148 |
+
n_types=n_types,
|
| 149 |
+
worst_type=_e(str(worst_type)),
|
| 150 |
+
worst_pct=worst_pct,
|
| 151 |
+
)
|
| 152 |
+
template = labels.get(
|
| 153 |
+
"levers_robustness_phrase",
|
| 154 |
+
"Déficit projeté de {engine} sur le corpus réel : "
|
| 155 |
+
"{deficit_pct} points de CER cumulés sur {n_types} dégradations.",
|
| 156 |
+
)
|
| 157 |
+
return template.format(
|
| 158 |
+
engine=engine, deficit_pct=deficit_pct, n_types=n_types,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
_FORMATTERS = {
|
| 163 |
+
"dominant_recoverable_class": _format_dominant_recoverable,
|
| 164 |
+
"pareto_concentration": _format_pareto_concentration,
|
| 165 |
+
"complementarity_observation": _format_complementarity,
|
| 166 |
+
"lexical_modernization_observation": _format_lexical_modernization,
|
| 167 |
+
"robustness_projection_observation": _format_robustness_projection,
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _importance_label(importance: int, labels: dict[str, str]) -> str:
|
| 172 |
+
if importance >= 70:
|
| 173 |
+
return labels.get("levers_importance_high", "Important")
|
| 174 |
+
if importance >= 40:
|
| 175 |
+
return labels.get("levers_importance_medium", "À noter")
|
| 176 |
+
return labels.get("levers_importance_low", "Mineur")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _importance_color(importance: int) -> str:
|
| 180 |
+
if importance >= 70:
|
| 181 |
+
return "#c2410c" # orange profond
|
| 182 |
+
if importance >= 40:
|
| 183 |
+
return "#0369a1" # bleu
|
| 184 |
+
return "#6b7280" # gris
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def build_levers_section_html(
|
| 188 |
+
levers: Iterable,
|
| 189 |
+
labels: Optional[dict[str, str]] = None,
|
| 190 |
+
) -> str:
|
| 191 |
+
"""Construit la section HTML des leviers.
|
| 192 |
+
|
| 193 |
+
Parameters
|
| 194 |
+
----------
|
| 195 |
+
levers:
|
| 196 |
+
Itérable de ``Lever`` (ou de dicts avec ``type``,
|
| 197 |
+
``importance``, ``payload``).
|
| 198 |
+
labels:
|
| 199 |
+
Dict i18n. Clés attendues sous le préfixe ``levers_``.
|
| 200 |
+
|
| 201 |
+
Returns
|
| 202 |
+
-------
|
| 203 |
+
str
|
| 204 |
+
Section HTML, ou ``""`` si aucun levier exploitable.
|
| 205 |
+
"""
|
| 206 |
+
labels = labels or {}
|
| 207 |
+
cards: list[str] = []
|
| 208 |
+
for lever in levers:
|
| 209 |
+
# Accepter Lever ou dict
|
| 210 |
+
if hasattr(lever, "as_dict"):
|
| 211 |
+
data = lever.as_dict()
|
| 212 |
+
elif isinstance(lever, dict):
|
| 213 |
+
data = lever
|
| 214 |
+
else:
|
| 215 |
+
continue
|
| 216 |
+
lv_type = data.get("type")
|
| 217 |
+
importance = int(data.get("importance") or 0)
|
| 218 |
+
payload = data.get("payload") or {}
|
| 219 |
+
if not lv_type:
|
| 220 |
+
continue
|
| 221 |
+
formatter = _FORMATTERS.get(lv_type)
|
| 222 |
+
if formatter is None:
|
| 223 |
+
continue
|
| 224 |
+
try:
|
| 225 |
+
sentence = formatter(payload, labels)
|
| 226 |
+
except Exception:
|
| 227 |
+
continue
|
| 228 |
+
if not sentence:
|
| 229 |
+
continue
|
| 230 |
+
type_label = _lever_label(lv_type, labels)
|
| 231 |
+
imp_label = _importance_label(importance, labels)
|
| 232 |
+
imp_color = _importance_color(importance)
|
| 233 |
+
cards.append(
|
| 234 |
+
'<div class="lever-card" style="border:1px solid #e5e7eb;'
|
| 235 |
+
'border-left:4px solid ' + imp_color + ';'
|
| 236 |
+
'border-radius:.4rem;padding:.7rem .9rem;'
|
| 237 |
+
'margin:.5rem 0;background:#fafafa">'
|
| 238 |
+
f'<div style="display:flex;justify-content:space-between;'
|
| 239 |
+
f'align-items:center;margin-bottom:.3rem;font-size:.8rem">'
|
| 240 |
+
f'<span style="font-weight:600;text-transform:uppercase;'
|
| 241 |
+
f'letter-spacing:.5px;color:#374151">'
|
| 242 |
+
f'{_e(type_label)}</span>'
|
| 243 |
+
f'<span style="color:{imp_color};font-weight:600">'
|
| 244 |
+
f'{_e(imp_label)}</span>'
|
| 245 |
+
f'</div>'
|
| 246 |
+
f'<div style="font-size:.95rem;line-height:1.45">'
|
| 247 |
+
f'{sentence}</div>'
|
| 248 |
+
'</div>'
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
if not cards:
|
| 252 |
+
return ""
|
| 253 |
+
|
| 254 |
+
title = labels.get("levers_title", "Leviers d'amélioration")
|
| 255 |
+
note = labels.get(
|
| 256 |
+
"levers_note",
|
| 257 |
+
"Observations factuelles synthétisées depuis les modules "
|
| 258 |
+
"d'analyse. Aucune recommandation imposée — c'est au "
|
| 259 |
+
"chercheur de juger ce qui est exploitable selon son "
|
| 260 |
+
"workflow.",
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
parts = [
|
| 264 |
+
'<section class="levers-section" style="margin:1.5rem 0">',
|
| 265 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 266 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
|
| 267 |
+
f'{_e(note)}</div>',
|
| 268 |
+
]
|
| 269 |
+
parts.extend(cards)
|
| 270 |
+
parts.append('</section>')
|
| 271 |
+
return "".join(parts)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
__all__ = [
|
| 275 |
+
"build_levers_section_html",
|
| 276 |
+
]
|
|
@@ -0,0 +1,575 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 82 — A.I.9 : section « Leviers d'amélioration ».
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. Modèle ``Lever`` + registre.
|
| 6 |
+
2. Les 5 détecteurs : ``dominant_recoverable_class``,
|
| 7 |
+
``pareto_concentration``, ``complementarity_observation``,
|
| 8 |
+
``lexical_modernization_observation``,
|
| 9 |
+
``robustness_projection_observation``.
|
| 10 |
+
3. Pipeline ``detect_levers`` (ordre, robustesse aux exceptions).
|
| 11 |
+
4. Rendu HTML : cards, anti-injection, masquage adaptatif.
|
| 12 |
+
5. Anti-hallucination : chaque chiffre rendu est dans le payload.
|
| 13 |
+
6. Complétude i18n FR/EN.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import re
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from picarones.core.levers import (
|
| 23 |
+
Lever,
|
| 24 |
+
LeverImportance,
|
| 25 |
+
LeverType,
|
| 26 |
+
detect_complementarity_observation,
|
| 27 |
+
detect_dominant_recoverable_class,
|
| 28 |
+
detect_levers,
|
| 29 |
+
detect_lexical_modernization_observation,
|
| 30 |
+
detect_pareto_concentration,
|
| 31 |
+
detect_robustness_projection_observation,
|
| 32 |
+
iter_lever_detectors,
|
| 33 |
+
)
|
| 34 |
+
from picarones.report.levers_render import build_levers_section_html
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 38 |
+
# 1. Modèle + registre
|
| 39 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class TestModel:
|
| 43 |
+
def test_lever_as_dict(self) -> None:
|
| 44 |
+
lv = Lever(
|
| 45 |
+
type=LeverType.DOMINANT_RECOVERABLE_CLASS,
|
| 46 |
+
importance=LeverImportance.HIGH,
|
| 47 |
+
payload={"engine": "t", "share_recoverable_pct": 65.0},
|
| 48 |
+
engines_involved=("t",),
|
| 49 |
+
)
|
| 50 |
+
d = lv.as_dict()
|
| 51 |
+
assert d["type"] == "dominant_recoverable_class"
|
| 52 |
+
assert d["importance"] == 70
|
| 53 |
+
assert d["engines_involved"] == ["t"]
|
| 54 |
+
|
| 55 |
+
def test_registry_contains_five_detectors(self) -> None:
|
| 56 |
+
types = {e.lever_type for e in iter_lever_detectors()}
|
| 57 |
+
assert LeverType.DOMINANT_RECOVERABLE_CLASS in types
|
| 58 |
+
assert LeverType.PARETO_CONCENTRATION in types
|
| 59 |
+
assert LeverType.COMPLEMENTARITY_OBSERVATION in types
|
| 60 |
+
assert LeverType.LEXICAL_MODERNIZATION_OBSERVATION in types
|
| 61 |
+
assert LeverType.ROBUSTNESS_PROJECTION_OBSERVATION in types
|
| 62 |
+
|
| 63 |
+
def test_registry_priority_sorted(self) -> None:
|
| 64 |
+
priorities = [e.priority for e in iter_lever_detectors()]
|
| 65 |
+
assert priorities == sorted(priorities)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 69 |
+
# 2. Détecteur dominant_recoverable_class
|
| 70 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class TestDominantRecoverable:
|
| 74 |
+
def test_emits_when_share_above_threshold(self) -> None:
|
| 75 |
+
data = {"engines": [{
|
| 76 |
+
"name": "t",
|
| 77 |
+
"aggregated_taxonomy": {
|
| 78 |
+
"case_error": 30,
|
| 79 |
+
"ligature_error": 10,
|
| 80 |
+
"abbreviation_error": 25, # 65 récupérables
|
| 81 |
+
"lacuna": 20,
|
| 82 |
+
"diacritic_error": 15,
|
| 83 |
+
},
|
| 84 |
+
}]}
|
| 85 |
+
levers = detect_dominant_recoverable_class(data)
|
| 86 |
+
assert len(levers) == 1
|
| 87 |
+
lv = levers[0]
|
| 88 |
+
assert lv.payload["engine"] == "t"
|
| 89 |
+
assert lv.payload["n_recoverable"] == 65
|
| 90 |
+
assert lv.payload["n_total_errors"] == 100
|
| 91 |
+
assert lv.payload["share_recoverable_pct"] == 65.0
|
| 92 |
+
assert lv.importance == LeverImportance.HIGH
|
| 93 |
+
|
| 94 |
+
def test_silent_when_below_threshold(self) -> None:
|
| 95 |
+
data = {"engines": [{
|
| 96 |
+
"name": "t",
|
| 97 |
+
"aggregated_taxonomy": {"lacuna": 80, "case_error": 20},
|
| 98 |
+
}]}
|
| 99 |
+
assert detect_dominant_recoverable_class(data) == []
|
| 100 |
+
|
| 101 |
+
def test_silent_when_no_taxonomy(self) -> None:
|
| 102 |
+
data = {"engines": [{"name": "t"}]}
|
| 103 |
+
assert detect_dominant_recoverable_class(data) == []
|
| 104 |
+
|
| 105 |
+
def test_top_classes_sorted_descending(self) -> None:
|
| 106 |
+
data = {"engines": [{
|
| 107 |
+
"name": "t",
|
| 108 |
+
"aggregated_taxonomy": {
|
| 109 |
+
"case_error": 50,
|
| 110 |
+
"ligature_error": 5,
|
| 111 |
+
"abbreviation_error": 30,
|
| 112 |
+
},
|
| 113 |
+
}]}
|
| 114 |
+
lv = detect_dominant_recoverable_class(data)[0]
|
| 115 |
+
names = [c["class"] for c in lv.payload["top_classes"]]
|
| 116 |
+
assert names == ["case_error", "abbreviation_error", "ligature_error"]
|
| 117 |
+
|
| 118 |
+
def test_accepts_counts_subdict(self) -> None:
|
| 119 |
+
data = {"engines": [{
|
| 120 |
+
"name": "t",
|
| 121 |
+
"aggregated_taxonomy": {"counts": {"case_error": 60, "lacuna": 40}},
|
| 122 |
+
}]}
|
| 123 |
+
levers = detect_dominant_recoverable_class(data)
|
| 124 |
+
assert len(levers) == 1
|
| 125 |
+
assert levers[0].payload["n_recoverable"] == 60
|
| 126 |
+
|
| 127 |
+
def test_medium_when_share_in_30_50(self) -> None:
|
| 128 |
+
data = {"engines": [{
|
| 129 |
+
"name": "t",
|
| 130 |
+
"aggregated_taxonomy": {"case_error": 35, "lacuna": 65},
|
| 131 |
+
}]}
|
| 132 |
+
lv = detect_dominant_recoverable_class(data)[0]
|
| 133 |
+
assert lv.importance == LeverImportance.MEDIUM
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 137 |
+
# 3. Détecteur pareto_concentration
|
| 138 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class TestParetoConcentration:
|
| 142 |
+
def test_concentrated_corpus(self) -> None:
|
| 143 |
+
# 10 docs : 2 catastrophiques (CER 0.8), 8 OK (CER 0.05) → 80 %
|
| 144 |
+
# du CER total est concentré sur 20 % des docs.
|
| 145 |
+
data = {
|
| 146 |
+
"ranking": [{"engine": "t", "mean_cer": 0.20}],
|
| 147 |
+
"per_doc_cer": {"t": [0.8, 0.8] + [0.05] * 8},
|
| 148 |
+
}
|
| 149 |
+
levers = detect_pareto_concentration(data)
|
| 150 |
+
assert len(levers) == 1
|
| 151 |
+
p = levers[0].payload
|
| 152 |
+
assert p["n_docs"] == 10
|
| 153 |
+
assert p["n_docs_top"] == 2
|
| 154 |
+
assert p["cer_share_pct"] >= 70
|
| 155 |
+
|
| 156 |
+
def test_uniform_corpus_silent(self) -> None:
|
| 157 |
+
data = {
|
| 158 |
+
"ranking": [{"engine": "t", "mean_cer": 0.10}],
|
| 159 |
+
"per_doc_cer": {"t": [0.10] * 10},
|
| 160 |
+
}
|
| 161 |
+
assert detect_pareto_concentration(data) == []
|
| 162 |
+
|
| 163 |
+
def test_reads_engine_per_doc(self) -> None:
|
| 164 |
+
data = {
|
| 165 |
+
"ranking": [{"engine": "t", "mean_cer": 0.20}],
|
| 166 |
+
"engines": [{
|
| 167 |
+
"name": "t",
|
| 168 |
+
"per_doc": [
|
| 169 |
+
{"cer": 0.9}, {"cer": 0.9},
|
| 170 |
+
{"cer": 0.05}, {"cer": 0.05}, {"cer": 0.05},
|
| 171 |
+
{"cer": 0.05}, {"cer": 0.05}, {"cer": 0.05},
|
| 172 |
+
{"cer": 0.05}, {"cer": 0.05},
|
| 173 |
+
],
|
| 174 |
+
}],
|
| 175 |
+
}
|
| 176 |
+
levers = detect_pareto_concentration(data)
|
| 177 |
+
assert len(levers) == 1
|
| 178 |
+
|
| 179 |
+
def test_no_ranking_silent(self) -> None:
|
| 180 |
+
assert detect_pareto_concentration({}) == []
|
| 181 |
+
|
| 182 |
+
def test_no_per_doc_silent(self) -> None:
|
| 183 |
+
data = {"ranking": [{"engine": "t", "mean_cer": 0.10}]}
|
| 184 |
+
assert detect_pareto_concentration(data) == []
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 188 |
+
# 4. Détecteur complementarity_observation
|
| 189 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class TestComplementarity:
|
| 193 |
+
def test_emits_when_relative_gap_above_threshold(self) -> None:
|
| 194 |
+
data = {"inter_engine_analysis": {
|
| 195 |
+
"complementarity_gap": {
|
| 196 |
+
"absolute_gap": 0.10,
|
| 197 |
+
"relative_gap": 0.30,
|
| 198 |
+
"best_engine": "t",
|
| 199 |
+
"best_recall": 0.70,
|
| 200 |
+
"oracle_recall": 0.80,
|
| 201 |
+
},
|
| 202 |
+
}}
|
| 203 |
+
levers = detect_complementarity_observation(data)
|
| 204 |
+
assert len(levers) == 1
|
| 205 |
+
p = levers[0].payload
|
| 206 |
+
assert p["best_engine"] == "t"
|
| 207 |
+
assert p["absolute_gap_pct"] == 10.0
|
| 208 |
+
assert p["relative_gap_pct"] == 30.0
|
| 209 |
+
|
| 210 |
+
def test_silent_when_below_threshold(self) -> None:
|
| 211 |
+
data = {"inter_engine_analysis": {
|
| 212 |
+
"complementarity_gap": {"absolute_gap": 0.02, "relative_gap": 0.05},
|
| 213 |
+
}}
|
| 214 |
+
assert detect_complementarity_observation(data) == []
|
| 215 |
+
|
| 216 |
+
def test_silent_when_no_data(self) -> None:
|
| 217 |
+
assert detect_complementarity_observation({}) == []
|
| 218 |
+
|
| 219 |
+
def test_high_when_relative_gap_above_50(self) -> None:
|
| 220 |
+
data = {"inter_engine_analysis": {
|
| 221 |
+
"complementarity_gap": {"absolute_gap": 0.30, "relative_gap": 0.60},
|
| 222 |
+
}}
|
| 223 |
+
lv = detect_complementarity_observation(data)[0]
|
| 224 |
+
assert lv.importance == LeverImportance.HIGH
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 228 |
+
# 5. Détecteur lexical_modernization_observation
|
| 229 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
class TestLexicalModernization:
|
| 233 |
+
def test_emits_top_three(self) -> None:
|
| 234 |
+
data = {"engines": [{
|
| 235 |
+
"name": "gpt4o",
|
| 236 |
+
"lexical_modernization": {
|
| 237 |
+
"n_gt_tokens": 50,
|
| 238 |
+
"tokens": {
|
| 239 |
+
"maistre": {"n_total": 10, "n_modernized": 10,
|
| 240 |
+
"rate_modernized": 1.0,
|
| 241 |
+
"variants": {"maître": 10}},
|
| 242 |
+
"veoir": {"n_total": 5, "n_modernized": 5,
|
| 243 |
+
"rate_modernized": 1.0,
|
| 244 |
+
"variants": {"voir": 5}},
|
| 245 |
+
"nostre": {"n_total": 8, "n_modernized": 6,
|
| 246 |
+
"rate_modernized": 0.75,
|
| 247 |
+
"variants": {"notre": 6}},
|
| 248 |
+
"ami": {"n_total": 3, "n_modernized": 0,
|
| 249 |
+
"rate_modernized": 0.0, "variants": {}},
|
| 250 |
+
},
|
| 251 |
+
},
|
| 252 |
+
}]}
|
| 253 |
+
levers = detect_lexical_modernization_observation(data)
|
| 254 |
+
assert len(levers) == 1
|
| 255 |
+
top = levers[0].payload["top_tokens"]
|
| 256 |
+
gt_tokens = [t["gt_token"] for t in top]
|
| 257 |
+
# Tri par rate desc, puis n_total desc → maistre, veoir, nostre
|
| 258 |
+
assert gt_tokens == ["maistre", "veoir", "nostre"]
|
| 259 |
+
assert levers[0].importance == LeverImportance.HIGH
|
| 260 |
+
|
| 261 |
+
def test_silent_when_no_tokens_above_min_rate(self) -> None:
|
| 262 |
+
data = {"engines": [{
|
| 263 |
+
"name": "t",
|
| 264 |
+
"lexical_modernization": {
|
| 265 |
+
"tokens": {"a": {"n_total": 10, "n_modernized": 1,
|
| 266 |
+
"rate_modernized": 0.10, "variants": {}}},
|
| 267 |
+
},
|
| 268 |
+
}]}
|
| 269 |
+
assert detect_lexical_modernization_observation(data) == []
|
| 270 |
+
|
| 271 |
+
def test_silent_when_n_total_below_min(self) -> None:
|
| 272 |
+
data = {"engines": [{
|
| 273 |
+
"name": "t",
|
| 274 |
+
"lexical_modernization": {
|
| 275 |
+
"tokens": {"a": {"n_total": 1, "n_modernized": 1,
|
| 276 |
+
"rate_modernized": 1.0, "variants": {}}},
|
| 277 |
+
},
|
| 278 |
+
}]}
|
| 279 |
+
assert detect_lexical_modernization_observation(data) == []
|
| 280 |
+
|
| 281 |
+
def test_silent_when_no_lexical_field(self) -> None:
|
| 282 |
+
data = {"engines": [{"name": "t"}]}
|
| 283 |
+
assert detect_lexical_modernization_observation(data) == []
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 287 |
+
# 6. Détecteur robustness_projection_observation
|
| 288 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class TestRobustnessProjection:
|
| 292 |
+
def test_emits_when_deficit_above_threshold(self) -> None:
|
| 293 |
+
data = {"robustness_projection_aggregated": {
|
| 294 |
+
"tess": {
|
| 295 |
+
"total_expected_deficit": 0.06,
|
| 296 |
+
"n_degradation_types": 2,
|
| 297 |
+
"worst_degradation_type": "noise",
|
| 298 |
+
"worst_degradation_deficit": 0.04,
|
| 299 |
+
},
|
| 300 |
+
}}
|
| 301 |
+
levers = detect_robustness_projection_observation(data)
|
| 302 |
+
assert len(levers) == 1
|
| 303 |
+
p = levers[0].payload
|
| 304 |
+
assert p["engine"] == "tess"
|
| 305 |
+
assert p["total_expected_deficit_pct"] == 6.0
|
| 306 |
+
assert p["worst_degradation_type"] == "noise"
|
| 307 |
+
assert levers[0].importance == LeverImportance.HIGH
|
| 308 |
+
|
| 309 |
+
def test_silent_when_deficit_too_low(self) -> None:
|
| 310 |
+
data = {"robustness_projection_aggregated": {
|
| 311 |
+
"tess": {"total_expected_deficit": 0.005},
|
| 312 |
+
}}
|
| 313 |
+
assert detect_robustness_projection_observation(data) == []
|
| 314 |
+
|
| 315 |
+
def test_silent_when_no_data(self) -> None:
|
| 316 |
+
assert detect_robustness_projection_observation({}) == []
|
| 317 |
+
|
| 318 |
+
def test_sorted_by_deficit_descending(self) -> None:
|
| 319 |
+
data = {"robustness_projection_aggregated": {
|
| 320 |
+
"a": {"total_expected_deficit": 0.03,
|
| 321 |
+
"n_degradation_types": 1},
|
| 322 |
+
"b": {"total_expected_deficit": 0.08,
|
| 323 |
+
"n_degradation_types": 2},
|
| 324 |
+
}}
|
| 325 |
+
levers = detect_robustness_projection_observation(data)
|
| 326 |
+
assert [lv.payload["engine"] for lv in levers] == ["b", "a"]
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 330 |
+
# 7. Pipeline detect_levers
|
| 331 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
class TestDetectLevers:
|
| 335 |
+
def test_aggregates_multiple_types(self) -> None:
|
| 336 |
+
data = {
|
| 337 |
+
"engines": [{
|
| 338 |
+
"name": "t",
|
| 339 |
+
"aggregated_taxonomy": {"case_error": 60, "lacuna": 40},
|
| 340 |
+
}],
|
| 341 |
+
"robustness_projection_aggregated": {
|
| 342 |
+
"t": {"total_expected_deficit": 0.07,
|
| 343 |
+
"n_degradation_types": 2},
|
| 344 |
+
},
|
| 345 |
+
}
|
| 346 |
+
levers = detect_levers(data)
|
| 347 |
+
types = [lv.type for lv in levers]
|
| 348 |
+
assert LeverType.DOMINANT_RECOVERABLE_CLASS in types
|
| 349 |
+
assert LeverType.ROBUSTNESS_PROJECTION_OBSERVATION in types
|
| 350 |
+
|
| 351 |
+
def test_sorted_by_importance_desc(self) -> None:
|
| 352 |
+
# HIGH (robustness 7%) avant MEDIUM (recoverable 35%)
|
| 353 |
+
data = {
|
| 354 |
+
"engines": [{
|
| 355 |
+
"name": "t",
|
| 356 |
+
"aggregated_taxonomy": {"case_error": 35, "lacuna": 65},
|
| 357 |
+
}],
|
| 358 |
+
"robustness_projection_aggregated": {
|
| 359 |
+
"t": {"total_expected_deficit": 0.07,
|
| 360 |
+
"n_degradation_types": 2},
|
| 361 |
+
},
|
| 362 |
+
}
|
| 363 |
+
levers = detect_levers(data)
|
| 364 |
+
importances = [int(lv.importance) for lv in levers]
|
| 365 |
+
assert importances == sorted(importances, reverse=True)
|
| 366 |
+
|
| 367 |
+
def test_empty_input_returns_empty(self) -> None:
|
| 368 |
+
assert detect_levers({}) == []
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 372 |
+
# 8. Rendu HTML
|
| 373 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _load_labels(lang: str) -> dict:
|
| 377 |
+
p = (
|
| 378 |
+
Path(__file__).parent.parent
|
| 379 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 380 |
+
)
|
| 381 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
class TestRender:
|
| 385 |
+
def test_empty_returns_empty(self) -> None:
|
| 386 |
+
assert build_levers_section_html([]) == ""
|
| 387 |
+
|
| 388 |
+
def test_card_per_lever(self) -> None:
|
| 389 |
+
levers = [
|
| 390 |
+
Lever(
|
| 391 |
+
type=LeverType.DOMINANT_RECOVERABLE_CLASS,
|
| 392 |
+
importance=LeverImportance.HIGH,
|
| 393 |
+
payload={"engine": "t", "share_recoverable_pct": 65.0,
|
| 394 |
+
"n_recoverable": 65, "n_total_errors": 100,
|
| 395 |
+
"top_classes": [{"class": "case_error", "count": 50}]},
|
| 396 |
+
),
|
| 397 |
+
]
|
| 398 |
+
labels = _load_labels("fr")
|
| 399 |
+
html = build_levers_section_html(levers, labels)
|
| 400 |
+
assert "lever-card" in html
|
| 401 |
+
assert "65" in html
|
| 402 |
+
assert "case_error" in html
|
| 403 |
+
assert "Important" in html
|
| 404 |
+
|
| 405 |
+
def test_anti_injection(self) -> None:
|
| 406 |
+
levers = [
|
| 407 |
+
Lever(
|
| 408 |
+
type=LeverType.DOMINANT_RECOVERABLE_CLASS,
|
| 409 |
+
importance=LeverImportance.HIGH,
|
| 410 |
+
payload={"engine": "<script>alert(1)</script>",
|
| 411 |
+
"share_recoverable_pct": 60.0,
|
| 412 |
+
"n_recoverable": 60, "n_total_errors": 100,
|
| 413 |
+
"top_classes": []},
|
| 414 |
+
),
|
| 415 |
+
]
|
| 416 |
+
html = build_levers_section_html(levers, _load_labels("fr"))
|
| 417 |
+
assert "<script>alert" not in html
|
| 418 |
+
assert "<script>" in html
|
| 419 |
+
|
| 420 |
+
def test_unknown_type_skipped(self) -> None:
|
| 421 |
+
# Lever-like dict avec type inconnu → ignoré
|
| 422 |
+
bad = {"type": "unknown_type", "importance": 70, "payload": {}}
|
| 423 |
+
html = build_levers_section_html([bad], _load_labels("fr"))
|
| 424 |
+
assert html == ""
|
| 425 |
+
|
| 426 |
+
def test_accepts_dict_input(self) -> None:
|
| 427 |
+
d = {
|
| 428 |
+
"type": "complementarity_observation",
|
| 429 |
+
"importance": 40,
|
| 430 |
+
"payload": {"absolute_gap_pct": 12.0, "relative_gap_pct": 25.0,
|
| 431 |
+
"absolute_gap": 0.12, "relative_gap": 0.25},
|
| 432 |
+
}
|
| 433 |
+
html = build_levers_section_html([d], _load_labels("fr"))
|
| 434 |
+
assert "12" in html and "25" in html
|
| 435 |
+
|
| 436 |
+
def test_renders_in_english(self) -> None:
|
| 437 |
+
levers = [
|
| 438 |
+
Lever(
|
| 439 |
+
type=LeverType.PARETO_CONCENTRATION,
|
| 440 |
+
importance=LeverImportance.HIGH,
|
| 441 |
+
payload={"engine": "t", "n_docs": 10, "n_docs_top": 2,
|
| 442 |
+
"top_share_pct": 20.0,
|
| 443 |
+
"cer_share_of_total": 0.78,
|
| 444 |
+
"cer_share_pct": 78.0},
|
| 445 |
+
),
|
| 446 |
+
]
|
| 447 |
+
html = build_levers_section_html(levers, _load_labels("en"))
|
| 448 |
+
assert "Improvement leverages" in html
|
| 449 |
+
assert "78" in html
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 453 |
+
# 9. Anti-hallucination : chaque chiffre rendu provient du payload
|
| 454 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _numbers_in(s: str) -> set[str]:
|
| 458 |
+
"""Extrait les nombres du HTML rendu visible.
|
| 459 |
+
|
| 460 |
+
On retire :
|
| 461 |
+
- les styles inline ;
|
| 462 |
+
- les entités HTML (``'`` ne contient pas le chiffre 27) ;
|
| 463 |
+
- les balises elles-mêmes (``<h3>`` ne contient pas le chiffre 3).
|
| 464 |
+
"""
|
| 465 |
+
s_clean = re.sub(r'style="[^"]*"', "", s)
|
| 466 |
+
s_clean = re.sub(r"&#x?[0-9a-fA-F]+;", "", s_clean)
|
| 467 |
+
s_clean = re.sub(r"<[^>]+>", " ", s_clean)
|
| 468 |
+
return set(re.findall(r"\d+(?:\.\d+)?", s_clean))
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _payload_numbers(payload: dict) -> set[str]:
|
| 472 |
+
out: set[str] = set()
|
| 473 |
+
def _walk(v):
|
| 474 |
+
if isinstance(v, (int, float)):
|
| 475 |
+
out.add(str(v))
|
| 476 |
+
# Aussi forme entière "65" si 65.0
|
| 477 |
+
if isinstance(v, float) and v.is_integer():
|
| 478 |
+
out.add(str(int(v)))
|
| 479 |
+
elif isinstance(v, dict):
|
| 480 |
+
for vv in v.values():
|
| 481 |
+
_walk(vv)
|
| 482 |
+
elif isinstance(v, list):
|
| 483 |
+
for vv in v:
|
| 484 |
+
_walk(vv)
|
| 485 |
+
_walk(payload)
|
| 486 |
+
return out
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
class TestAntiHallucination:
|
| 490 |
+
def test_dominant_numbers_traceable_fr(self) -> None:
|
| 491 |
+
lv = Lever(
|
| 492 |
+
type=LeverType.DOMINANT_RECOVERABLE_CLASS,
|
| 493 |
+
importance=LeverImportance.HIGH,
|
| 494 |
+
payload={"engine": "tess", "share_recoverable_pct": 65.0,
|
| 495 |
+
"n_recoverable": 65, "n_total_errors": 100,
|
| 496 |
+
"top_classes": [{"class": "case_error", "count": 50}]},
|
| 497 |
+
)
|
| 498 |
+
html = build_levers_section_html([lv], _load_labels("fr"))
|
| 499 |
+
rendered = _numbers_in(html)
|
| 500 |
+
allowed = _payload_numbers(lv.payload)
|
| 501 |
+
# Tout chiffre du HTML doit être dans le payload
|
| 502 |
+
assert rendered.issubset(allowed), (
|
| 503 |
+
f"non traçable : {rendered - allowed}"
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
def test_pareto_numbers_traceable_en(self) -> None:
|
| 507 |
+
lv = Lever(
|
| 508 |
+
type=LeverType.PARETO_CONCENTRATION,
|
| 509 |
+
importance=LeverImportance.HIGH,
|
| 510 |
+
payload={"engine": "tess", "n_docs": 47, "n_docs_top": 9,
|
| 511 |
+
"top_share_pct": 19.1,
|
| 512 |
+
"cer_share_of_total": 0.81,
|
| 513 |
+
"cer_share_pct": 80.7},
|
| 514 |
+
)
|
| 515 |
+
html = build_levers_section_html([lv], _load_labels("en"))
|
| 516 |
+
rendered = _numbers_in(html)
|
| 517 |
+
allowed = _payload_numbers(lv.payload)
|
| 518 |
+
assert rendered.issubset(allowed), (
|
| 519 |
+
f"non traçable : {rendered - allowed}"
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
def test_robustness_numbers_traceable_fr(self) -> None:
|
| 523 |
+
lv = Lever(
|
| 524 |
+
type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION,
|
| 525 |
+
importance=LeverImportance.HIGH,
|
| 526 |
+
payload={"engine": "tess", "total_expected_deficit": 0.058,
|
| 527 |
+
"total_expected_deficit_pct": 5.8,
|
| 528 |
+
"n_degradation_types": 3,
|
| 529 |
+
"worst_degradation_type": "noise",
|
| 530 |
+
"worst_degradation_deficit": 0.041,
|
| 531 |
+
"worst_degradation_deficit_pct": 4.1},
|
| 532 |
+
)
|
| 533 |
+
html = build_levers_section_html([lv], _load_labels("fr"))
|
| 534 |
+
rendered = _numbers_in(html)
|
| 535 |
+
allowed = _payload_numbers(lv.payload)
|
| 536 |
+
assert rendered.issubset(allowed), (
|
| 537 |
+
f"non traçable : {rendered - allowed}"
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 542 |
+
# 10. Complétude i18n
|
| 543 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
_LEVERS_KEYS = {
|
| 547 |
+
"levers_title", "levers_note",
|
| 548 |
+
"levers_top_classes",
|
| 549 |
+
"levers_importance_high", "levers_importance_medium",
|
| 550 |
+
"levers_importance_low",
|
| 551 |
+
"levers_label_dominant_recoverable_class",
|
| 552 |
+
"levers_label_pareto_concentration",
|
| 553 |
+
"levers_label_complementarity_observation",
|
| 554 |
+
"levers_label_lexical_modernization_observation",
|
| 555 |
+
"levers_label_robustness_projection_observation",
|
| 556 |
+
"levers_dominant_recoverable_phrase",
|
| 557 |
+
"levers_pareto_phrase",
|
| 558 |
+
"levers_complementarity_phrase",
|
| 559 |
+
"levers_complementarity_phrase_with_engine",
|
| 560 |
+
"levers_lexical_phrase",
|
| 561 |
+
"levers_robustness_phrase",
|
| 562 |
+
"levers_robustness_phrase_with_worst",
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
class TestI18nCompleteness:
|
| 567 |
+
def test_fr_has_all_keys(self) -> None:
|
| 568 |
+
d = _load_labels("fr")
|
| 569 |
+
missing = _LEVERS_KEYS - d.keys()
|
| 570 |
+
assert not missing, f"manque FR : {missing}"
|
| 571 |
+
|
| 572 |
+
def test_en_has_all_keys(self) -> None:
|
| 573 |
+
d = _load_labels("en")
|
| 574 |
+
missing = _LEVERS_KEYS - d.keys()
|
| 575 |
+
assert not missing, f"manque EN : {missing}"
|