Spaces:
Running
sprint77: taxonomie comparative côte-à-côte (A.I.4 chantier 3, clôture A.I.4)
Browse filesTroisième et dernier chantier d'A.I.4. Le détecteur
error_profile_outlier (Sprint 19) signale qu'un moteur a un profil
taxonomique éloigné de ses concurrents, mais sans visualisation.
Ce sprint répond à « deux moteurs ont le même CER global, mais
lequel fait des erreurs plus récupérables ? ».
- Nouveau module picarones/core/taxonomy_comparison.py :
- compare_taxonomies(engine_a, counts_a, engine_b, counts_b)
normalise en proportions, deltas signés, agrège par
récupérabilité éditoriale (recoverable/difficult/irrecoverable).
- Constante RECOVERABILITY exportée.
- Nouveau module picarones/report/taxonomy_comparison_render.py :
- build_taxonomy_comparison_html : titre + note + diagramme
miroir SVG + tableau résumé.
- Mirror chart : ligne par classe, barres horizontales A à
gauche / B à droite, étiquettes au centre, % à côté, couleur
selon récupérabilité (vert/orange/rouge), échelle normalisée.
- Tableau récupérabilité 3×2 avec pastilles colorées.
- Adaptive : "" si data None ou pas de classes.
- Choix éditorial assumé : la classification est un guide
pragmatique, pas un verdict imposé.
- +6 clés i18n FR/EN.
- +18 tests dans test_sprint77_taxonomy_comparison.py.
A.I.4 livré bout-en-bout (Sprints 75-77).
Tests : 2645 passed, 2 skipped, 0 failed.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +58 -0
- CLAUDE.md +2 -1
- picarones/core/taxonomy_comparison.py +161 -0
- picarones/report/i18n/en.json +7 -1
- picarones/report/i18n/fr.json +7 -1
- picarones/report/taxonomy_comparison_render.py +233 -0
- tests/test_sprint77_taxonomy_comparison.py +215 -0
|
@@ -16,6 +16,64 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 76 — A.I.4 chantier 2 : évolution intra-document
|
| 20 |
des classes taxonomiques (couche calcul + heatmap SVG).**
|
| 21 |
Deuxième des trois chantiers d'A.I.4. ``line_metrics.py``
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 77 — A.I.4 chantier 3 : taxonomie comparative
|
| 20 |
+
côte-à-côte (clôture A.I.4).** Troisième et dernier chantier
|
| 21 |
+
d'A.I.4. Le détecteur ``error_profile_outlier`` (Sprint 19)
|
| 22 |
+
signale qu'un moteur a un profil taxonomique éloigné de ses
|
| 23 |
+
concurrents, mais sans visualisation. Ce sprint répond à
|
| 24 |
+
*« deux moteurs ont le même CER global, mais lequel fait des
|
| 25 |
+
erreurs plus récupérables ? »*.
|
| 26 |
+
- Nouveau module `picarones/core/taxonomy_comparison.py` :
|
| 27 |
+
- ``compare_taxonomies(engine_a, counts_a, engine_b, counts_b)``
|
| 28 |
+
normalise les comptes en proportions (somme = 1), calcule
|
| 29 |
+
les ``deltas`` signés (b - a) par classe, et agrège par
|
| 30 |
+
niveau de **récupérabilité éditoriale** :
|
| 31 |
+
|
| 32 |
+
- ``recoverable`` : case_error, ligature_error,
|
| 33 |
+
abbreviation_error (corrigeables par post-processing
|
| 34 |
+
trivial)
|
| 35 |
+
- ``difficult`` : diacritic_error, visual_confusion,
|
| 36 |
+
hapax (effort modéré requis)
|
| 37 |
+
- ``irrecoverable`` : lacuna, oov_character,
|
| 38 |
+
segmentation_error (impossibles sans relire l'image)
|
| 39 |
+
- Constante ``RECOVERABILITY`` exportée pour utilisation
|
| 40 |
+
externe.
|
| 41 |
+
- Retourne ``None`` si les deux moteurs ont 0 erreur chacun.
|
| 42 |
+
- Nouveau module `picarones/report/taxonomy_comparison_render.py` :
|
| 43 |
+
- ``build_taxonomy_comparison_html(data, labels)`` produit
|
| 44 |
+
titre + note d'usage + diagramme miroir SVG + tableau
|
| 45 |
+
résumé par catégorie.
|
| 46 |
+
- ``_build_mirror_chart_svg`` server-side : une ligne par
|
| 47 |
+
classe, deux barres horizontales (A à gauche, B à droite),
|
| 48 |
+
étiquette de classe au centre, valeurs en %. Couleur de
|
| 49 |
+
la barre selon ``recoverability`` (vert / orange / rouge).
|
| 50 |
+
Échelle normalisée à la proportion max pour visibilité
|
| 51 |
+
uniforme.
|
| 52 |
+
- ``_build_recoverability_summary_html`` : tableau 3 lignes
|
| 53 |
+
(Récupérable / Difficile / Irrécupérable) × 2 colonnes
|
| 54 |
+
(engine A / engine B) avec pastille colorée et %.
|
| 55 |
+
- Adaptive : ``""`` si ``data is None`` ou pas de classes.
|
| 56 |
+
- Anti-injection systématique sur noms de moteurs et labels
|
| 57 |
+
i18n. Accessible : ``role="img"`` + ``aria-label``.
|
| 58 |
+
- +6 clés i18n FR/EN (``taxocomp_*``) avec template Python
|
| 59 |
+
``{engine_a}/{engine_b}``.
|
| 60 |
+
- +18 tests dans `test_sprint77_taxonomy_comparison.py` :
|
| 61 |
+
couche calcul (7 cas — proportions, deltas signés,
|
| 62 |
+
récupérabilité, vide, classe unique chez un moteur, totaux,
|
| 63 |
+
sanité ``RECOVERABILITY`` couvre toutes ``ERROR_CLASSES``),
|
| 64 |
+
rendu (7 cas — None, SVG, noms moteurs, labels classes,
|
| 65 |
+
résumé récupérabilité, % affichés, codes couleur), anti-
|
| 66 |
+
injection (nom moteur + label i18n), complétude i18n FR + EN.
|
| 67 |
+
- **Choix éditorial assumé** : la classification
|
| 68 |
+
``recoverable``/``difficult``/``irrecoverable`` est un
|
| 69 |
+
**guide pragmatique pour le chercheur**, pas un verdict
|
| 70 |
+
imposé. La note explicative dit textuellement « à CER égal,
|
| 71 |
+
un moteur dont les erreurs sont majoritairement vertes est
|
| 72 |
+
préférable pour une édition critique » — c'est au chercheur
|
| 73 |
+
de juger selon ses besoins.
|
| 74 |
+
- **A.I.4 livré bout-en-bout** : co-occurrence (Sprint 75) +
|
| 75 |
+
intra-document (Sprint 76) + comparatif (Sprint 77).
|
| 76 |
+
|
| 77 |
- **Sprint 76 — A.I.4 chantier 2 : évolution intra-document
|
| 78 |
des classes taxonomiques (couche calcul + heatmap SVG).**
|
| 79 |
Deuxième des trois chantiers d'A.I.4. ``line_metrics.py``
|
|
@@ -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 |
| 76 | **Sprint 45 du plan d'évolution 2026 — A.I.4 chantier 2 : évolution intra-document des classes taxonomiques (couche calcul + heatmap SVG)**. `line_metrics.py` (Sprint 10) avait déjà heatmap CER×position ; ce sprint l'étend à toutes les classes taxonomiques. Nouveau module `picarones/core/taxonomy_intra_doc.py` : `compute_taxonomy_position_heatmap(reference, hypothesis, n_bins=10)` calcule par classe le compte par tranche de position, réutilise classification mot-à-mot Sprint 5 en gardant `i1` (position GT) et binnifiant via `floor(i1/n_gt*n_bins)`. `_classify_word_pair` variante pure. `_bin_for_position` clip 0..n_bins-1. `ValueError` si n_bins≤0, `None` si GT vide. Nouveau module `picarones/report/taxonomy_intra_doc_render.py` : `build_taxonomy_intra_doc_html` produit heatmap SVG class×position avec gradient blanc→orange profond, densité relative au max de chaque classe (met en évidence les positions concentrées), filtrage classes avec ≥1 erreur, étiquettes positions/classes, accessible. Adaptive : `""` si None/no_errors/aucune classe avec erreurs. +3 clés i18n FR/EN. +16 tests (calcul 8 cas dont identité/début/fin/uniforme/breakdown, rendu 5 cas, anti-injection, complétude i18n). **Verrou levé** : un chercheur voit où chaque type d'erreur apparaît — distingue erreurs de marge (concentrées) vs scribe (uniformes). |
|
| 211 |
| 75 | **Sprint 44 du plan d'évolution 2026 — A.I.4 chantier 1 : co-occurrence taxonomique (couche calcul + heatmap SVG)**. Premier des 3 chantiers d'A.I.4. Répond à « quelles classes d'erreur tendent à apparaître ensemble ? » — utile pour stratifier *a posteriori*. Nouveau module `picarones/core/taxonomy_cooccurrence.py` : `compute_taxonomy_cooccurrence(per_doc_classes, min_doc_count=1, top_n_pairs=10)` calcule l'indice de Jaccard entre paires de classes au niveau document (présence binaire), symétrique, diagonale=1.0, filtrage classes anecdotiques via min_doc_count, top_pairs triées Jaccard décroissant. Retourne None si vide. Nouveau module `picarones/report/taxonomy_cooccurrence_render.py` : `build_taxonomy_cooccurrence_html` produit titre + note + heatmap SVG + table top_pairs. `_build_heatmap_svg` server-side avec cellules colorées blanc→bleu profond, valeur affichée si >0.05, étiquettes rotées -45° en haut/normales à gauche, accessible (role/aria-label). Adaptive : "" si None ou matrice vide. +5 clés i18n FR/EN. +22 tests (calcul 11 cas dont toujours/jamais ensemble, diagonale, symétrie, chevauchement partiel, min_doc_count, top_pairs triées, none doc skipped ; rendu 7 cas ; anti-injection ; complétude i18n). **Verrou levé** : un chercheur voit d'un coup d'œil quelles classes d'erreur sont corrélées dans son corpus. |
|
| 212 |
| 74 | **Sprint 43 du plan d'évolution 2026 — A.I.3 chantier 1 : encart HTML « Ce corpus est-il habituel ? » (clôture A.I.3)**. Suite directe Sprint 73 (couche calcul + détecteur narratif). Nouveau module `picarones/report/baseline_render.py` : `build_corpus_difficulty_baseline_html(percentile_data, historical_values, labels)` produit phrase factuelle + boxplot SVG, phrase template auto-sélectionnée selon harder_than_usual/easier_than_usual/usual flags. `_build_difficulty_boxplot_svg` server-side avec moustache min→max, boîte Q1→Q3, médiane, point courant **coloré adaptive** (bleu si <Q1 plus facile, rouge si >Q3 plus difficile, vert sinon habituel), étiquettes numériques, accessible (role/aria-label). Helper `_quantiles` méthode inclusive gère N=0/1. Adaptive : `""` si percentile_data None, boxplot omis si historical_values vide. +4 clés i18n FR/EN avec templates Python `{current:.2f}/{percentile:.0f}/{n_runs}`. +20 tests (quantiles 3 cas, SVG 8 cas dont couleurs/dégénéré, HTML 6 cas, anti-injection, complétude i18n). **Verrou levé** : un bench avec historique SQLite chargé voit en tête de rapport « ce corpus est plus difficile que la moyenne — au 88ᵉ percentile des 47 corpus précédents » avec boxplot. **A.I.3 livré bout-en-bout** (Sprint 73 calc+narrative + Sprint 74 vue HTML). |
|
|
@@ -294,7 +295,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 294 |
## Contexte développement
|
| 295 |
|
| 296 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 297 |
-
- **Tests** :
|
| 298 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 299 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 300 |
- **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 |
+
| 77 | **Sprint 46 du plan d'évolution 2026 — A.I.4 chantier 3 : taxonomie comparative côte-à-côte (clôture A.I.4)**. Troisième chantier d'A.I.4. Répond à « deux moteurs ont le même CER global, mais lequel fait des erreurs plus récupérables ? ». Nouveau module `picarones/core/taxonomy_comparison.py` : `compare_taxonomies(engine_a, counts_a, engine_b, counts_b)` normalise en proportions, calcule deltas signés, agrège par niveau de **récupérabilité éditoriale** (recoverable: case/ligature/abbreviation ; difficult: diacritic/visual/hapax ; irrecoverable: lacuna/oov/segmentation). Constante `RECOVERABILITY` exportée. Retourne None si vide. Nouveau module `picarones/report/taxonomy_comparison_render.py` : `build_taxonomy_comparison_html` produit titre + note + diagramme miroir SVG + tableau résumé par catégorie. `_build_mirror_chart_svg` server-side : ligne par classe, barres horizontales A à gauche / B à droite, étiquette au centre, %, couleur selon récupérabilité (vert/orange/rouge), échelle normalisée. `_build_recoverability_summary_html` : tableau 3×2 avec pastilles colorées. Adaptive : "" si None ou pas de classes. +6 clés i18n FR/EN. +18 tests (calcul 7 cas dont sanité RECOVERABILITY couvre ERROR_CLASSES, rendu 7 cas, anti-injection, i18n). **Choix éditorial assumé** : classification recoverable/difficult/irrecoverable est un guide pragmatique, pas un verdict — note explicative dit « à CER égal, un moteur dont les erreurs sont majoritairement vertes est préférable pour une édition critique ». **A.I.4 livré bout-en-bout** (Sprints 75-77). |
|
| 211 |
| 76 | **Sprint 45 du plan d'évolution 2026 — A.I.4 chantier 2 : évolution intra-document des classes taxonomiques (couche calcul + heatmap SVG)**. `line_metrics.py` (Sprint 10) avait déjà heatmap CER×position ; ce sprint l'étend à toutes les classes taxonomiques. Nouveau module `picarones/core/taxonomy_intra_doc.py` : `compute_taxonomy_position_heatmap(reference, hypothesis, n_bins=10)` calcule par classe le compte par tranche de position, réutilise classification mot-à-mot Sprint 5 en gardant `i1` (position GT) et binnifiant via `floor(i1/n_gt*n_bins)`. `_classify_word_pair` variante pure. `_bin_for_position` clip 0..n_bins-1. `ValueError` si n_bins≤0, `None` si GT vide. Nouveau module `picarones/report/taxonomy_intra_doc_render.py` : `build_taxonomy_intra_doc_html` produit heatmap SVG class×position avec gradient blanc→orange profond, densité relative au max de chaque classe (met en évidence les positions concentrées), filtrage classes avec ≥1 erreur, étiquettes positions/classes, accessible. Adaptive : `""` si None/no_errors/aucune classe avec erreurs. +3 clés i18n FR/EN. +16 tests (calcul 8 cas dont identité/début/fin/uniforme/breakdown, rendu 5 cas, anti-injection, complétude i18n). **Verrou levé** : un chercheur voit où chaque type d'erreur apparaît — distingue erreurs de marge (concentrées) vs scribe (uniformes). |
|
| 212 |
| 75 | **Sprint 44 du plan d'évolution 2026 — A.I.4 chantier 1 : co-occurrence taxonomique (couche calcul + heatmap SVG)**. Premier des 3 chantiers d'A.I.4. Répond à « quelles classes d'erreur tendent à apparaître ensemble ? » — utile pour stratifier *a posteriori*. Nouveau module `picarones/core/taxonomy_cooccurrence.py` : `compute_taxonomy_cooccurrence(per_doc_classes, min_doc_count=1, top_n_pairs=10)` calcule l'indice de Jaccard entre paires de classes au niveau document (présence binaire), symétrique, diagonale=1.0, filtrage classes anecdotiques via min_doc_count, top_pairs triées Jaccard décroissant. Retourne None si vide. Nouveau module `picarones/report/taxonomy_cooccurrence_render.py` : `build_taxonomy_cooccurrence_html` produit titre + note + heatmap SVG + table top_pairs. `_build_heatmap_svg` server-side avec cellules colorées blanc→bleu profond, valeur affichée si >0.05, étiquettes rotées -45° en haut/normales à gauche, accessible (role/aria-label). Adaptive : "" si None ou matrice vide. +5 clés i18n FR/EN. +22 tests (calcul 11 cas dont toujours/jamais ensemble, diagonale, symétrie, chevauchement partiel, min_doc_count, top_pairs triées, none doc skipped ; rendu 7 cas ; anti-injection ; complétude i18n). **Verrou levé** : un chercheur voit d'un coup d'œil quelles classes d'erreur sont corrélées dans son corpus. |
|
| 213 |
| 74 | **Sprint 43 du plan d'évolution 2026 — A.I.3 chantier 1 : encart HTML « Ce corpus est-il habituel ? » (clôture A.I.3)**. Suite directe Sprint 73 (couche calcul + détecteur narratif). Nouveau module `picarones/report/baseline_render.py` : `build_corpus_difficulty_baseline_html(percentile_data, historical_values, labels)` produit phrase factuelle + boxplot SVG, phrase template auto-sélectionnée selon harder_than_usual/easier_than_usual/usual flags. `_build_difficulty_boxplot_svg` server-side avec moustache min→max, boîte Q1→Q3, médiane, point courant **coloré adaptive** (bleu si <Q1 plus facile, rouge si >Q3 plus difficile, vert sinon habituel), étiquettes numériques, accessible (role/aria-label). Helper `_quantiles` méthode inclusive gère N=0/1. Adaptive : `""` si percentile_data None, boxplot omis si historical_values vide. +4 clés i18n FR/EN avec templates Python `{current:.2f}/{percentile:.0f}/{n_runs}`. +20 tests (quantiles 3 cas, SVG 8 cas dont couleurs/dégénéré, HTML 6 cas, anti-injection, complétude i18n). **Verrou levé** : un bench avec historique SQLite chargé voit en tête de rapport « ce corpus est plus difficile que la moyenne — au 88ᵉ percentile des 47 corpus précédents » avec boxplot. **A.I.3 livré bout-en-bout** (Sprint 73 calc+narrative + Sprint 74 vue HTML). |
|
|
|
|
| 295 |
## Contexte développement
|
| 296 |
|
| 297 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 298 |
+
- **Tests** : 2645 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 — co-occurrence Jaccard + heatmap intra-document class×position + diagramme miroir comparatif inter-moteurs**)
|
| 299 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 300 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 301 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3).
|
| 2 |
+
|
| 3 |
+
Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4).
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale
|
| 8 |
+
qu'un moteur a un profil taxonomique éloigné de ses concurrents,
|
| 9 |
+
mais le rapport n'expose pas cette différence visuellement. Ce
|
| 10 |
+
sprint répond à *« deux moteurs ont le même CER global, mais lequel
|
| 11 |
+
fait des erreurs plus récupérables ? »*.
|
| 12 |
+
|
| 13 |
+
Lecture concrète
|
| 14 |
+
----------------
|
| 15 |
+
- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables
|
| 16 |
+
par un post-processing trivial (récupérables).
|
| 17 |
+
- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) →
|
| 18 |
+
irrécupérables sans relire l'image.
|
| 19 |
+
|
| 20 |
+
À CER égal, A est massivement préférable pour un workflow
|
| 21 |
+
d'édition critique. Cette vue rend la différence visible.
|
| 22 |
+
|
| 23 |
+
Catégorisation des classes
|
| 24 |
+
--------------------------
|
| 25 |
+
On annote chaque classe d'erreur d'un degré de **récupérabilité**
|
| 26 |
+
(critère éditorial pragmatique, pas verdict imposé) :
|
| 27 |
+
|
| 28 |
+
- ``recoverable`` : récupérable par post-processing trivial
|
| 29 |
+
(case_error, ligature_error, abbreviation_error)
|
| 30 |
+
- ``difficult`` : récupérable au prix d'un effort
|
| 31 |
+
(diacritic_error, visual_confusion, hapax)
|
| 32 |
+
- ``irrecoverable`` : impossible à corriger sans l'image
|
| 33 |
+
(lacuna, oov_character, segmentation_error)
|
| 34 |
+
|
| 35 |
+
L'utilisateur consulte ces catégories comme un guide, pas un
|
| 36 |
+
verdict — c'est lui qui juge selon ses besoins éditoriaux.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
from __future__ import annotations
|
| 40 |
+
|
| 41 |
+
import logging
|
| 42 |
+
from typing import Optional
|
| 43 |
+
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Classification éditoriale. Documentée dans la docstring.
|
| 48 |
+
RECOVERABILITY: dict[str, str] = {
|
| 49 |
+
"case_error": "recoverable",
|
| 50 |
+
"ligature_error": "recoverable",
|
| 51 |
+
"abbreviation_error": "recoverable",
|
| 52 |
+
"diacritic_error": "difficult",
|
| 53 |
+
"visual_confusion": "difficult",
|
| 54 |
+
"hapax": "difficult",
|
| 55 |
+
"lacuna": "irrecoverable",
|
| 56 |
+
"oov_character": "irrecoverable",
|
| 57 |
+
"segmentation_error": "irrecoverable",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _normalize_counts(counts: dict[str, int]) -> dict[str, float]:
|
| 62 |
+
"""Convertit un dict de comptes en proportions [0, 1]."""
|
| 63 |
+
total = sum(counts.values())
|
| 64 |
+
if total <= 0:
|
| 65 |
+
return {k: 0.0 for k in counts}
|
| 66 |
+
return {k: v / total for k, v in counts.items()}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def compare_taxonomies(
|
| 70 |
+
engine_a_name: str,
|
| 71 |
+
engine_a_counts: dict[str, int],
|
| 72 |
+
engine_b_name: str,
|
| 73 |
+
engine_b_counts: dict[str, int],
|
| 74 |
+
) -> Optional[dict]:
|
| 75 |
+
"""Compare deux profils taxonomiques.
|
| 76 |
+
|
| 77 |
+
Parameters
|
| 78 |
+
----------
|
| 79 |
+
engine_a_name, engine_b_name:
|
| 80 |
+
Noms d'identification des moteurs (utilisés dans le rendu).
|
| 81 |
+
engine_a_counts, engine_b_counts:
|
| 82 |
+
Maps ``{class_name: count}`` produites par
|
| 83 |
+
``aggregate_taxonomy``.
|
| 84 |
+
|
| 85 |
+
Returns
|
| 86 |
+
-------
|
| 87 |
+
Optional[dict]
|
| 88 |
+
``{
|
| 89 |
+
"engine_a": str, "engine_b": str,
|
| 90 |
+
"total_a": int, "total_b": int,
|
| 91 |
+
"classes": list[str], # classes apparaissant chez A ou B
|
| 92 |
+
"proportions_a": dict[str, float],
|
| 93 |
+
"proportions_b": dict[str, float],
|
| 94 |
+
"deltas": dict[str, float], # prop_b - prop_a (signé)
|
| 95 |
+
"recoverability": dict[str, str], # mapping class → niveau
|
| 96 |
+
"totals_by_recoverability": {
|
| 97 |
+
"recoverable": {"a": float, "b": float},
|
| 98 |
+
"difficult": {"a": float, "b": float},
|
| 99 |
+
"irrecoverable": {"a": float, "b": float},
|
| 100 |
+
},
|
| 101 |
+
}``
|
| 102 |
+
Ou ``None`` si les deux moteurs ont 0 erreur chacun.
|
| 103 |
+
"""
|
| 104 |
+
if engine_a_name == engine_b_name:
|
| 105 |
+
# On accepte des comparaisons même si les noms sont
|
| 106 |
+
# identiques (cas tests), mais on émet un warning.
|
| 107 |
+
logger.warning(
|
| 108 |
+
"[taxonomy_comparison] engine_a et engine_b ont le même nom : %s",
|
| 109 |
+
engine_a_name,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
total_a = sum(engine_a_counts.values()) if engine_a_counts else 0
|
| 113 |
+
total_b = sum(engine_b_counts.values()) if engine_b_counts else 0
|
| 114 |
+
if total_a == 0 and total_b == 0:
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
classes = sorted(set(engine_a_counts) | set(engine_b_counts))
|
| 118 |
+
if not classes:
|
| 119 |
+
return None
|
| 120 |
+
|
| 121 |
+
prop_a = _normalize_counts(
|
| 122 |
+
{c: engine_a_counts.get(c, 0) for c in classes},
|
| 123 |
+
)
|
| 124 |
+
prop_b = _normalize_counts(
|
| 125 |
+
{c: engine_b_counts.get(c, 0) for c in classes},
|
| 126 |
+
)
|
| 127 |
+
deltas = {c: prop_b[c] - prop_a[c] for c in classes}
|
| 128 |
+
|
| 129 |
+
# Agrégat par récupérabilité (utile pour la lecture rapide)
|
| 130 |
+
totals_recov: dict[str, dict[str, float]] = {
|
| 131 |
+
"recoverable": {"a": 0.0, "b": 0.0},
|
| 132 |
+
"difficult": {"a": 0.0, "b": 0.0},
|
| 133 |
+
"irrecoverable": {"a": 0.0, "b": 0.0},
|
| 134 |
+
}
|
| 135 |
+
for cls in classes:
|
| 136 |
+
level = RECOVERABILITY.get(cls, "difficult")
|
| 137 |
+
if level not in totals_recov:
|
| 138 |
+
level = "difficult"
|
| 139 |
+
totals_recov[level]["a"] += prop_a[cls]
|
| 140 |
+
totals_recov[level]["b"] += prop_b[cls]
|
| 141 |
+
|
| 142 |
+
return {
|
| 143 |
+
"engine_a": engine_a_name,
|
| 144 |
+
"engine_b": engine_b_name,
|
| 145 |
+
"total_a": total_a,
|
| 146 |
+
"total_b": total_b,
|
| 147 |
+
"classes": classes,
|
| 148 |
+
"proportions_a": prop_a,
|
| 149 |
+
"proportions_b": prop_b,
|
| 150 |
+
"deltas": deltas,
|
| 151 |
+
"recoverability": {
|
| 152 |
+
cls: RECOVERABILITY.get(cls, "difficult") for cls in classes
|
| 153 |
+
},
|
| 154 |
+
"totals_by_recoverability": totals_recov,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
__all__ = [
|
| 159 |
+
"RECOVERABILITY",
|
| 160 |
+
"compare_taxonomies",
|
| 161 |
+
]
|
|
@@ -250,5 +250,11 @@
|
|
| 250 |
"taxocooc_jaccard_label": "Jaccard",
|
| 251 |
"intradoc_title": "Intra-document evolution of error classes",
|
| 252 |
"intradoc_note": "Heatmap class × position: relative density per class (darker = concentrated). A class concentrated in the first column suggests a margin error; a uniform distribution suggests a scribe error.",
|
| 253 |
-
"intradoc_n_words": "Computed on {n_words_gt} GT words, split into {n_bins} bins."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
|
|
|
| 250 |
"taxocooc_jaccard_label": "Jaccard",
|
| 251 |
"intradoc_title": "Intra-document evolution of error classes",
|
| 252 |
"intradoc_note": "Heatmap class × position: relative density per class (darker = concentrated). A class concentrated in the first column suggests a margin error; a uniform distribution suggests a scribe error.",
|
| 253 |
+
"intradoc_n_words": "Computed on {n_words_gt} GT words, split into {n_bins} bins.",
|
| 254 |
+
"taxocomp_title": "Taxonomic profile: {engine_a} vs {engine_b}",
|
| 255 |
+
"taxocomp_note": "Mirror chart of error proportions per class. Color by editorial recoverability (green = correctable, red = irrecoverable). At equal global CER, an engine whose errors are mostly green is preferable for a critical edition.",
|
| 256 |
+
"taxocomp_level_label": "Category",
|
| 257 |
+
"taxocomp_recoverable": "Recoverable",
|
| 258 |
+
"taxocomp_difficult": "Difficult",
|
| 259 |
+
"taxocomp_irrecoverable": "Irrecoverable"
|
| 260 |
}
|
|
@@ -250,5 +250,11 @@
|
|
| 250 |
"taxocooc_jaccard_label": "Jaccard",
|
| 251 |
"intradoc_title": "Évolution intra-document des classes d'erreur",
|
| 252 |
"intradoc_note": "Heatmap class × position : densité relative par classe (plus foncé = concentré). Une classe concentrée dans la première colonne suggère une erreur de marge ; une distribution uniforme suggère une erreur de scribe.",
|
| 253 |
-
"intradoc_n_words": "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
|
|
|
| 250 |
"taxocooc_jaccard_label": "Jaccard",
|
| 251 |
"intradoc_title": "Évolution intra-document des classes d'erreur",
|
| 252 |
"intradoc_note": "Heatmap class × position : densité relative par classe (plus foncé = concentré). Une classe concentrée dans la première colonne suggère une erreur de marge ; une distribution uniforme suggère une erreur de scribe.",
|
| 253 |
+
"intradoc_n_words": "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches.",
|
| 254 |
+
"taxocomp_title": "Profil taxonomique : {engine_a} vs {engine_b}",
|
| 255 |
+
"taxocomp_note": "Diagramme miroir des proportions d'erreurs par classe. Couleur selon récupérabilité éditoriale (vert = corrigeable, rouge = irrécupérable). À CER global égal, un moteur dont les erreurs sont majoritairement vertes est préférable pour une édition critique.",
|
| 256 |
+
"taxocomp_level_label": "Catégorie",
|
| 257 |
+
"taxocomp_recoverable": "Récupérable",
|
| 258 |
+
"taxocomp_difficult": "Difficile",
|
| 259 |
+
"taxocomp_irrecoverable": "Irrécupérable"
|
| 260 |
}
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML du diagramme miroir taxonomique — Sprint 77.
|
| 2 |
+
|
| 3 |
+
A.I.4 chantier 3 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Suite directe ``picarones/core/taxonomy_comparison.py``. Pattern
|
| 6 |
+
identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76) :
|
| 7 |
+
**server-side**, pas de JavaScript, anti-injection systématique.
|
| 8 |
+
|
| 9 |
+
Diagramme miroir
|
| 10 |
+
----------------
|
| 11 |
+
Une ligne par classe taxonomique, divisée en deux barres
|
| 12 |
+
horizontales :
|
| 13 |
+
|
| 14 |
+
- À **gauche** : barre du moteur A (orientée vers la gauche, du
|
| 15 |
+
centre vers le bord).
|
| 16 |
+
- À **droite** : barre du moteur B (orientée vers la droite).
|
| 17 |
+
- Couleur de la classe selon ``recoverability`` :
|
| 18 |
+
|
| 19 |
+
- vert (#5fa860) : ``recoverable``
|
| 20 |
+
- orange (#e0a050) : ``difficult``
|
| 21 |
+
- rouge (#d8553b) : ``irrecoverable``
|
| 22 |
+
|
| 23 |
+
Lecture immédiate : un moteur dont les barres tirent vers la
|
| 24 |
+
**gauche** sur du vert (case_error, ligature_error) et un moteur
|
| 25 |
+
qui tire à droite sur du rouge (lacuna) — la décision éditoriale
|
| 26 |
+
est évidente même si les CER globaux sont identiques.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from html import escape as _e
|
| 32 |
+
from typing import Optional
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
_RECOVERABILITY_COLORS = {
|
| 36 |
+
"recoverable": "#5fa860",
|
| 37 |
+
"difficult": "#e0a050",
|
| 38 |
+
"irrecoverable": "#d8553b",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _build_mirror_chart_svg(
|
| 43 |
+
data: dict,
|
| 44 |
+
*,
|
| 45 |
+
bar_max_width: int = 200,
|
| 46 |
+
row_height: int = 22,
|
| 47 |
+
label_width: int = 140,
|
| 48 |
+
margin_top: int = 50,
|
| 49 |
+
margin_bottom: int = 20,
|
| 50 |
+
) -> str:
|
| 51 |
+
"""Construit le diagramme miroir SVG."""
|
| 52 |
+
classes = data["classes"]
|
| 53 |
+
prop_a = data["proportions_a"]
|
| 54 |
+
prop_b = data["proportions_b"]
|
| 55 |
+
recov = data["recoverability"]
|
| 56 |
+
engine_a = data["engine_a"]
|
| 57 |
+
engine_b = data["engine_b"]
|
| 58 |
+
|
| 59 |
+
n_rows = len(classes)
|
| 60 |
+
if n_rows == 0:
|
| 61 |
+
return ""
|
| 62 |
+
|
| 63 |
+
# Échelle : on normalise à la valeur max de toutes les
|
| 64 |
+
# proportions (pour que la classe la plus présente atteigne
|
| 65 |
+
# bar_max_width).
|
| 66 |
+
max_prop = max(
|
| 67 |
+
max(prop_a.values(), default=0.0),
|
| 68 |
+
max(prop_b.values(), default=0.0),
|
| 69 |
+
)
|
| 70 |
+
if max_prop <= 0:
|
| 71 |
+
max_prop = 1.0 # évite division par zéro (cas dégénéré)
|
| 72 |
+
|
| 73 |
+
width = label_width + 2 * bar_max_width + 40
|
| 74 |
+
height = margin_top + n_rows * row_height + margin_bottom
|
| 75 |
+
center = width // 2
|
| 76 |
+
|
| 77 |
+
parts = [
|
| 78 |
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
| 79 |
+
f'width="{width}" height="{height}" '
|
| 80 |
+
f'viewBox="0 0 {width} {height}" '
|
| 81 |
+
f'role="img" aria-label="Diagramme miroir taxonomique">',
|
| 82 |
+
# En-têtes des deux moteurs
|
| 83 |
+
f'<text x="{center - bar_max_width // 2}" y="20" '
|
| 84 |
+
f'font-size="13" font-weight="600" fill="#333" '
|
| 85 |
+
f'text-anchor="middle">{_e(engine_a)}</text>',
|
| 86 |
+
f'<text x="{center + bar_max_width // 2}" y="20" '
|
| 87 |
+
f'font-size="13" font-weight="600" fill="#333" '
|
| 88 |
+
f'text-anchor="middle">{_e(engine_b)}</text>',
|
| 89 |
+
# Ligne centrale
|
| 90 |
+
f'<line x1="{center}" y1="{margin_top - 4}" '
|
| 91 |
+
f'x2="{center}" y2="{height - margin_bottom + 4}" '
|
| 92 |
+
f'stroke="#999" stroke-width="1"/>',
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
# Barres
|
| 96 |
+
for i, cls in enumerate(classes):
|
| 97 |
+
y = margin_top + i * row_height
|
| 98 |
+
level = recov.get(cls, "difficult")
|
| 99 |
+
color = _RECOVERABILITY_COLORS.get(level, "#888")
|
| 100 |
+
# Étiquette de classe au centre
|
| 101 |
+
parts.append(
|
| 102 |
+
f'<text x="{center}" y="{y + row_height // 2 + 4}" '
|
| 103 |
+
f'font-size="11" fill="#222" text-anchor="middle" '
|
| 104 |
+
f'font-family="monospace">{_e(cls)}</text>'
|
| 105 |
+
)
|
| 106 |
+
# Barre A (gauche)
|
| 107 |
+
a_width = (prop_a.get(cls, 0.0) / max_prop) * bar_max_width
|
| 108 |
+
if a_width > 0:
|
| 109 |
+
x_a = center - label_width // 2 - a_width
|
| 110 |
+
parts.append(
|
| 111 |
+
f'<rect x="{x_a:.1f}" y="{y + 3}" '
|
| 112 |
+
f'width="{a_width:.1f}" height="{row_height - 6}" '
|
| 113 |
+
f'fill="{color}" stroke="#666" stroke-width="0.5" '
|
| 114 |
+
f'opacity="0.85"/>'
|
| 115 |
+
)
|
| 116 |
+
# Valeur en %
|
| 117 |
+
parts.append(
|
| 118 |
+
f'<text x="{x_a - 3:.1f}" y="{y + row_height // 2 + 4}" '
|
| 119 |
+
f'font-size="10" fill="#444" text-anchor="end">'
|
| 120 |
+
f'{prop_a.get(cls, 0.0) * 100:.1f}%</text>'
|
| 121 |
+
)
|
| 122 |
+
# Barre B (droite)
|
| 123 |
+
b_width = (prop_b.get(cls, 0.0) / max_prop) * bar_max_width
|
| 124 |
+
if b_width > 0:
|
| 125 |
+
x_b = center + label_width // 2
|
| 126 |
+
parts.append(
|
| 127 |
+
f'<rect x="{x_b:.1f}" y="{y + 3}" '
|
| 128 |
+
f'width="{b_width:.1f}" height="{row_height - 6}" '
|
| 129 |
+
f'fill="{color}" stroke="#666" stroke-width="0.5" '
|
| 130 |
+
f'opacity="0.85"/>'
|
| 131 |
+
)
|
| 132 |
+
parts.append(
|
| 133 |
+
f'<text x="{x_b + b_width + 3:.1f}" '
|
| 134 |
+
f'y="{y + row_height // 2 + 4}" '
|
| 135 |
+
f'font-size="10" fill="#444" text-anchor="start">'
|
| 136 |
+
f'{prop_b.get(cls, 0.0) * 100:.1f}%</text>'
|
| 137 |
+
)
|
| 138 |
+
parts.append("</svg>")
|
| 139 |
+
return "".join(parts)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _build_recoverability_summary_html(
|
| 143 |
+
data: dict, labels: dict,
|
| 144 |
+
) -> str:
|
| 145 |
+
"""Encart résumé par catégorie de récupérabilité (3 lignes)."""
|
| 146 |
+
totals = data.get("totals_by_recoverability") or {}
|
| 147 |
+
if not totals:
|
| 148 |
+
return ""
|
| 149 |
+
label_recov = labels.get("taxocomp_recoverable", "Récupérable")
|
| 150 |
+
label_diff = labels.get("taxocomp_difficult", "Difficile")
|
| 151 |
+
label_irrec = labels.get("taxocomp_irrecoverable", "Irrécupérable")
|
| 152 |
+
rows = [
|
| 153 |
+
("recoverable", label_recov),
|
| 154 |
+
("difficult", label_diff),
|
| 155 |
+
("irrecoverable", label_irrec),
|
| 156 |
+
]
|
| 157 |
+
parts = [
|
| 158 |
+
'<table style="border-collapse:collapse;font-size:.85rem;'
|
| 159 |
+
'margin-top:.5rem">',
|
| 160 |
+
'<thead><tr>',
|
| 161 |
+
'<th style="padding:.2rem .5rem;text-align:left;'
|
| 162 |
+
'border-bottom:1px solid #ccc">'
|
| 163 |
+
f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>',
|
| 164 |
+
'<th style="padding:.2rem .5rem;text-align:right;'
|
| 165 |
+
'border-bottom:1px solid #ccc">'
|
| 166 |
+
f'{_e(_e(data["engine_a"]))}</th>',
|
| 167 |
+
'<th style="padding:.2rem .5rem;text-align:right;'
|
| 168 |
+
'border-bottom:1px solid #ccc">'
|
| 169 |
+
f'{_e(_e(data["engine_b"]))}</th>',
|
| 170 |
+
'</tr></thead><tbody>',
|
| 171 |
+
]
|
| 172 |
+
for level, label in rows:
|
| 173 |
+
cell = totals.get(level, {"a": 0.0, "b": 0.0})
|
| 174 |
+
color = _RECOVERABILITY_COLORS.get(level, "#888")
|
| 175 |
+
parts.append(
|
| 176 |
+
f'<tr>'
|
| 177 |
+
f'<td style="padding:.2rem .5rem">'
|
| 178 |
+
f'<span style="display:inline-block;width:10px;height:10px;'
|
| 179 |
+
f'background:{color};margin-right:.4rem;border-radius:2px"></span>'
|
| 180 |
+
f'{_e(label)}</td>'
|
| 181 |
+
f'<td style="padding:.2rem .5rem;text-align:right;'
|
| 182 |
+
f'font-family:monospace">{cell["a"] * 100:.1f}%</td>'
|
| 183 |
+
f'<td style="padding:.2rem .5rem;text-align:right;'
|
| 184 |
+
f'font-family:monospace">{cell["b"] * 100:.1f}%</td>'
|
| 185 |
+
f'</tr>'
|
| 186 |
+
)
|
| 187 |
+
parts.append("</tbody></table>")
|
| 188 |
+
return "".join(parts)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def build_taxonomy_comparison_html(
|
| 192 |
+
data: Optional[dict],
|
| 193 |
+
labels: Optional[dict[str, str]] = None,
|
| 194 |
+
) -> str:
|
| 195 |
+
"""Construit le bloc HTML de comparaison taxonomique entre 2 moteurs.
|
| 196 |
+
|
| 197 |
+
Retourne ``""`` si ``data is None`` ou aucune classe.
|
| 198 |
+
"""
|
| 199 |
+
if not data:
|
| 200 |
+
return ""
|
| 201 |
+
classes = data.get("classes") or []
|
| 202 |
+
if not classes:
|
| 203 |
+
return ""
|
| 204 |
+
labels = labels or {}
|
| 205 |
+
title_template = labels.get(
|
| 206 |
+
"taxocomp_title", "Profil taxonomique : {engine_a} vs {engine_b}",
|
| 207 |
+
)
|
| 208 |
+
title = title_template.format(
|
| 209 |
+
engine_a=data["engine_a"], engine_b=data["engine_b"],
|
| 210 |
+
)
|
| 211 |
+
note = labels.get(
|
| 212 |
+
"taxocomp_note",
|
| 213 |
+
"Diagramme miroir des proportions d'erreurs par classe. "
|
| 214 |
+
"Couleur selon récupérabilité éditoriale (vert = corrigeable, "
|
| 215 |
+
"rouge = irrécupérable). À CER global égal, un moteur dont les "
|
| 216 |
+
"erreurs sont majoritairement vertes est préférable pour une "
|
| 217 |
+
"édition critique.",
|
| 218 |
+
)
|
| 219 |
+
parts = [
|
| 220 |
+
'<div class="taxocomp" style="margin:1rem 0">',
|
| 221 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 222 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
|
| 223 |
+
f'{_e(note)}</div>',
|
| 224 |
+
_build_mirror_chart_svg(data),
|
| 225 |
+
_build_recoverability_summary_html(data, labels),
|
| 226 |
+
"</div>",
|
| 227 |
+
]
|
| 228 |
+
return "".join(parts)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
__all__ = [
|
| 232 |
+
"build_taxonomy_comparison_html",
|
| 233 |
+
]
|
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 77 — A.I.4 chantier 3 : taxonomie comparative.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``compare_taxonomies`` :
|
| 6 |
+
- Proportions correctement normalisées (somme = 1)
|
| 7 |
+
- Deltas signés (b - a)
|
| 8 |
+
- Catégorisation par récupérabilité
|
| 9 |
+
- Cas dégénéré : deux comptes vides → None
|
| 10 |
+
- Classes apparaissant chez un seul moteur
|
| 11 |
+
- Totaux par récupérabilité
|
| 12 |
+
2. Rendu HTML :
|
| 13 |
+
- Diagramme miroir SVG bien formé
|
| 14 |
+
- Tableau récupérabilité présent
|
| 15 |
+
- "" si data None
|
| 16 |
+
- "" si classes vides
|
| 17 |
+
3. Anti-injection : noms moteurs avec ``<script>``.
|
| 18 |
+
4. Complétude i18n FR/EN.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import json
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
from picarones.core.taxonomy_comparison import (
|
| 27 |
+
RECOVERABILITY,
|
| 28 |
+
compare_taxonomies,
|
| 29 |
+
)
|
| 30 |
+
from picarones.report.taxonomy_comparison_render import (
|
| 31 |
+
build_taxonomy_comparison_html,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 36 |
+
# 1. compare_taxonomies
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TestCompare:
|
| 41 |
+
def test_proportions_sum_to_one(self) -> None:
|
| 42 |
+
result = compare_taxonomies(
|
| 43 |
+
"A", {"case_error": 8, "lacuna": 2},
|
| 44 |
+
"B", {"case_error": 1, "lacuna": 9},
|
| 45 |
+
)
|
| 46 |
+
assert result is not None
|
| 47 |
+
assert sum(result["proportions_a"].values()) == 1.0
|
| 48 |
+
assert sum(result["proportions_b"].values()) == 1.0
|
| 49 |
+
|
| 50 |
+
def test_deltas_signed(self) -> None:
|
| 51 |
+
result = compare_taxonomies(
|
| 52 |
+
"A", {"case_error": 8, "lacuna": 2},
|
| 53 |
+
"B", {"case_error": 2, "lacuna": 8},
|
| 54 |
+
)
|
| 55 |
+
# B a plus de lacuna, moins de case_error
|
| 56 |
+
assert result["deltas"]["lacuna"] > 0
|
| 57 |
+
assert result["deltas"]["case_error"] < 0
|
| 58 |
+
|
| 59 |
+
def test_recoverability_categorization(self) -> None:
|
| 60 |
+
result = compare_taxonomies(
|
| 61 |
+
"A", {"case_error": 10}, # 100% recoverable
|
| 62 |
+
"B", {"lacuna": 10}, # 100% irrecoverable
|
| 63 |
+
)
|
| 64 |
+
totals = result["totals_by_recoverability"]
|
| 65 |
+
assert totals["recoverable"]["a"] == 1.0
|
| 66 |
+
assert totals["irrecoverable"]["b"] == 1.0
|
| 67 |
+
assert totals["recoverable"]["b"] == 0.0
|
| 68 |
+
assert totals["irrecoverable"]["a"] == 0.0
|
| 69 |
+
|
| 70 |
+
def test_returns_none_when_both_empty(self) -> None:
|
| 71 |
+
assert compare_taxonomies("A", {}, "B", {}) is None
|
| 72 |
+
assert compare_taxonomies("A", {"case_error": 0}, "B", {}) is None
|
| 73 |
+
|
| 74 |
+
def test_class_in_only_one_engine(self) -> None:
|
| 75 |
+
result = compare_taxonomies(
|
| 76 |
+
"A", {"case_error": 5},
|
| 77 |
+
"B", {"lacuna": 5, "case_error": 5},
|
| 78 |
+
)
|
| 79 |
+
# case_error présent chez les deux
|
| 80 |
+
assert result["proportions_a"]["case_error"] == 1.0
|
| 81 |
+
assert result["proportions_a"]["lacuna"] == 0.0
|
| 82 |
+
assert result["proportions_b"]["lacuna"] == 0.5
|
| 83 |
+
|
| 84 |
+
def test_totals_a_and_b_correct(self) -> None:
|
| 85 |
+
result = compare_taxonomies(
|
| 86 |
+
"A", {"case_error": 7, "lacuna": 3},
|
| 87 |
+
"B", {"case_error": 2, "lacuna": 8},
|
| 88 |
+
)
|
| 89 |
+
assert result["total_a"] == 10
|
| 90 |
+
assert result["total_b"] == 10
|
| 91 |
+
|
| 92 |
+
def test_recoverability_constant_complete(self) -> None:
|
| 93 |
+
# Sanité : RECOVERABILITY couvre toutes les classes du module
|
| 94 |
+
from picarones.core.taxonomy import ERROR_CLASSES
|
| 95 |
+
for cls in ERROR_CLASSES:
|
| 96 |
+
assert cls in RECOVERABILITY
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 100 |
+
# 2. Rendu HTML
|
| 101 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TestRender:
|
| 105 |
+
def test_returns_empty_when_none(self) -> None:
|
| 106 |
+
assert build_taxonomy_comparison_html(None) == ""
|
| 107 |
+
|
| 108 |
+
def test_renders_svg(self) -> None:
|
| 109 |
+
data = compare_taxonomies(
|
| 110 |
+
"Tesseract", {"case_error": 8, "lacuna": 2},
|
| 111 |
+
"Pero", {"case_error": 2, "lacuna": 8},
|
| 112 |
+
)
|
| 113 |
+
html = build_taxonomy_comparison_html(data)
|
| 114 |
+
assert "<svg" in html
|
| 115 |
+
assert "</svg>" in html
|
| 116 |
+
|
| 117 |
+
def test_engine_names_displayed(self) -> None:
|
| 118 |
+
data = compare_taxonomies(
|
| 119 |
+
"Tesseract", {"case_error": 5},
|
| 120 |
+
"Pero", {"lacuna": 5},
|
| 121 |
+
)
|
| 122 |
+
html = build_taxonomy_comparison_html(data)
|
| 123 |
+
assert "Tesseract" in html
|
| 124 |
+
assert "Pero" in html
|
| 125 |
+
|
| 126 |
+
def test_class_labels_present(self) -> None:
|
| 127 |
+
data = compare_taxonomies(
|
| 128 |
+
"A", {"case_error": 5},
|
| 129 |
+
"B", {"lacuna": 5},
|
| 130 |
+
)
|
| 131 |
+
html = build_taxonomy_comparison_html(data)
|
| 132 |
+
assert "case_error" in html
|
| 133 |
+
assert "lacuna" in html
|
| 134 |
+
|
| 135 |
+
def test_recoverability_summary_present(self) -> None:
|
| 136 |
+
data = compare_taxonomies(
|
| 137 |
+
"A", {"case_error": 5},
|
| 138 |
+
"B", {"lacuna": 5},
|
| 139 |
+
)
|
| 140 |
+
html = build_taxonomy_comparison_html(data)
|
| 141 |
+
assert "Récupérable" in html
|
| 142 |
+
assert "Irrécupérable" in html
|
| 143 |
+
|
| 144 |
+
def test_proportions_displayed(self) -> None:
|
| 145 |
+
data = compare_taxonomies(
|
| 146 |
+
"A", {"case_error": 8, "lacuna": 2},
|
| 147 |
+
"B", {"case_error": 2, "lacuna": 8},
|
| 148 |
+
)
|
| 149 |
+
html = build_taxonomy_comparison_html(data)
|
| 150 |
+
# 80.0% présent dans le SVG (proportion case_error de A)
|
| 151 |
+
assert "80.0%" in html
|
| 152 |
+
|
| 153 |
+
def test_color_codes_present(self) -> None:
|
| 154 |
+
data = compare_taxonomies(
|
| 155 |
+
"A", {"case_error": 5}, # recoverable → vert
|
| 156 |
+
"B", {"lacuna": 5}, # irrecoverable → rouge
|
| 157 |
+
)
|
| 158 |
+
html = build_taxonomy_comparison_html(data)
|
| 159 |
+
assert "#5fa860" in html # vert
|
| 160 |
+
assert "#d8553b" in html # rouge
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 164 |
+
# 3. Anti-injection
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class TestAntiInjection:
|
| 169 |
+
def test_engine_name_escaped(self) -> None:
|
| 170 |
+
data = compare_taxonomies(
|
| 171 |
+
"<script>alert(1)</script>", {"case_error": 5},
|
| 172 |
+
"Pero", {"lacuna": 5},
|
| 173 |
+
)
|
| 174 |
+
html = build_taxonomy_comparison_html(data)
|
| 175 |
+
assert "<script>alert" not in html
|
| 176 |
+
assert "<script>" in html
|
| 177 |
+
|
| 178 |
+
def test_label_via_i18n_escaped(self) -> None:
|
| 179 |
+
data = compare_taxonomies(
|
| 180 |
+
"A", {"case_error": 5}, "B", {"lacuna": 5},
|
| 181 |
+
)
|
| 182 |
+
labels = {"taxocomp_recoverable": "<b>Hack</b>"}
|
| 183 |
+
html = build_taxonomy_comparison_html(data, labels=labels)
|
| 184 |
+
assert "<b>Hack</b>" not in html
|
| 185 |
+
assert "<b>Hack</b>" in html
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 189 |
+
# 4. Complétude i18n
|
| 190 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class TestI18nCompleteness:
|
| 194 |
+
def _load(self, lang: str) -> dict:
|
| 195 |
+
path = (
|
| 196 |
+
Path(__file__).parent.parent
|
| 197 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 198 |
+
)
|
| 199 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 200 |
+
|
| 201 |
+
def test_all_keys_fr(self) -> None:
|
| 202 |
+
d = self._load("fr")
|
| 203 |
+
for key in (
|
| 204 |
+
"taxocomp_title", "taxocomp_note", "taxocomp_level_label",
|
| 205 |
+
"taxocomp_recoverable", "taxocomp_difficult",
|
| 206 |
+
"taxocomp_irrecoverable",
|
| 207 |
+
):
|
| 208 |
+
assert key in d, f"manque clé FR : {key}"
|
| 209 |
+
|
| 210 |
+
def test_all_keys_en(self) -> None:
|
| 211 |
+
d_fr = self._load("fr")
|
| 212 |
+
d_en = self._load("en")
|
| 213 |
+
for key in d_fr:
|
| 214 |
+
if key.startswith("taxocomp_"):
|
| 215 |
+
assert key in d_en, f"manque clé EN : {key}"
|