Spaces:
Running
sprint72: vue HTML Worst lines globale (A.I.1 chantier 1, clôture A.I.1)
Browse filesSuite directe Sprint 71 — la métrique rare-token recall est livrée,
ce sprint livre la vue HTML qui transcende les documents pour exposer
les lignes individuelles les plus mal transcrites du corpus.
- Nouveau module picarones/core/worst_lines.py :
- WorstLineEntry(rank, cer, engine_name, doc_id, line_index,
gt_line, hyp_line, script_type).
- extract_worst_lines(benchmark, top_n=20, engine_filter,
script_type_filter) collecte transversalement, filtre, trie
par CER décroissant, retourne top_n avec rang 1-based.
- Récupère les textes GT/hyp par re-split du DocumentResult à
l'index de ligne (suppose BenchmarkResult non-compacté).
- Lignes CER=0 ignorées.
- Nouveau module picarones/report/worst_lines_render.py :
- build_worst_lines_table_html avec colonnes Rang/CER (gradient
jaune→rouge)/Moteur/Doc/Ligne#/[Strate adaptive]/Diff GT→OCR.
- Diff caractère par caractère via diff_utils.compute_char_diff
(réutilisation Sprint 5).
- Anti-injection systématique. Retourne "" si vide.
- +25 tests dans test_sprint72_worst_lines.py.
Tests : 2548 passed, 2 skipped, 0 failed.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +44 -0
- CLAUDE.md +2 -1
- picarones/core/worst_lines.py +199 -0
- picarones/report/worst_lines_render.py +163 -0
- tests/test_sprint72_worst_lines.py +322 -0
|
@@ -16,6 +16,50 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 71 — A.I.1 chantier 2 : rare-token recall (couche
|
| 20 |
de calcul, démarrage de la résolution des critiques
|
| 21 |
structurelles A.I).** Premier sprint du chantier A.I qui
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 72 — A.I.1 chantier 1 : vue « Worst lines globale »
|
| 20 |
+
(clôture A.I.1).** Suite directe Sprint 71 : la roadmap A.I.1
|
| 21 |
+
comporte deux chantiers — la métrique rare-token recall (livrée)
|
| 22 |
+
et la vue HTML qui expose les lignes individuelles les plus mal
|
| 23 |
+
transcrites du corpus. Ce sprint livre la vue.
|
| 24 |
+
- Nouveau module `picarones/core/worst_lines.py` :
|
| 25 |
+
- Dataclass ``WorstLineEntry(rank, cer, engine_name, doc_id,
|
| 26 |
+
line_index, gt_line, hyp_line, script_type)``.
|
| 27 |
+
- ``extract_worst_lines(benchmark, top_n=20, engine_filter,
|
| 28 |
+
script_type_filter)`` collecte transversalement à tous les
|
| 29 |
+
moteurs et documents, filtre par moteur et par strate
|
| 30 |
+
(Sprint 45 ``doc_strata``), trie par CER décroissant, retourne
|
| 31 |
+
les ``top_n`` premières avec rang 1-based.
|
| 32 |
+
- Récupération des textes GT/hyp par re-split du
|
| 33 |
+
``DocumentResult.ground_truth`` / ``hypothesis`` à l'index de
|
| 34 |
+
ligne (cf. limite : suppose un ``BenchmarkResult``
|
| 35 |
+
non-compacté).
|
| 36 |
+
- Lignes avec ``cer == 0.0`` ignorées (pas dans le worst).
|
| 37 |
+
- Nouveau module `picarones/report/worst_lines_render.py` :
|
| 38 |
+
- ``build_worst_lines_table_html(entries, labels)`` : tableau
|
| 39 |
+
HTML server-side avec colonnes Rang / CER (cellule colorée
|
| 40 |
+
gradient jaune→rouge) / Moteur / Document / Ligne # /
|
| 41 |
+
[Strate] / Diff GT→OCR. Colonne strate **adaptive**
|
| 42 |
+
(omise si aucune entry n'a de ``script_type``).
|
| 43 |
+
- Diff caractère par caractère via
|
| 44 |
+
``diff_utils.compute_char_diff`` (réutilisation Sprint 5),
|
| 45 |
+
rendu inline avec rouge clair barré pour suppressions et vert
|
| 46 |
+
clair pour insertions.
|
| 47 |
+
- Anti-injection systématique sur engine_name, doc_id, GT/hyp
|
| 48 |
+
lines, labels i18n.
|
| 49 |
+
- Retourne ``""`` si la liste est vide (rapport adaptatif).
|
| 50 |
+
- +25 tests dans `test_sprint72_worst_lines.py` :
|
| 51 |
+
extraction (top_n, tri par CER décroissant, rang 1-based,
|
| 52 |
+
top_n=0, lignes CER=0 ignorées) ; filtres (par moteur, par
|
| 53 |
+
strate, valeurs inconnues) ; cas limites (pas de line_metrics,
|
| 54 |
+
benchmark vide, sans doc_strata, hyp plus courte que GT) ;
|
| 55 |
+
rendu (tableau, colonnes attendues, strate adaptive, cellule
|
| 56 |
+
CER colorée, diff rendu, % affiché) ; anti-injection
|
| 57 |
+
(engine_name, doc_id, GT line, label i18n).
|
| 58 |
+
- **Verrou levé** : un chercheur qui voit *« 5 % de mes lignes
|
| 59 |
+
ont un CER > 0,42 »* dans le rapport peut désormais voir
|
| 60 |
+
**quelles** lignes — diff inline, document parent, ligne #,
|
| 61 |
+
moteur — pour comprendre ce qui casse précisément.
|
| 62 |
+
|
| 63 |
- **Sprint 71 — A.I.1 chantier 2 : rare-token recall (couche
|
| 64 |
de calcul, démarrage de la résolution des critiques
|
| 65 |
structurelles A.I).** Premier sprint du chantier A.I qui
|
|
@@ -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 |
| 71 | **Sprint 40 du plan d'évolution 2026 — A.I.1 chantier 2 : rare-token recall (couche de calcul, démarrage de la résolution des critiques structurelles A.I)**. Premier sprint A.I qui s'attaque à la critique « la granularité ne s'arrête plus à la page ». Mesure le rappel sur les tokens rares (hapax + dis legomena, défaut `max_freq=2`) — répond à *« ce moteur préserve-t-il les noms propres rares qui m'intéressent pour l'indexation prosopographique ? »*. Nouveau module `picarones/core/rare_tokens.py` : `tokenize` Unicode-aware (contractions `L'an`/`d’une`, composés `peut-être`, apostrophe typographique `’` U+2019), `frequency_distribution(documents, case_sensitive)` → `{token: count}` corpus-wide, `extract_rare_tokens(documents, max_freq=2)` → `frozenset`, `compute_rare_token_recall(reference, hypothesis, rare_tokens)` retourne `{n_rare_tokens_in_reference, n_rare_tokens_recalled, recall, missed_tokens}` avec alignement bag-of-tokens multiplicitaire. **Pas d'enregistrement registre typé** (la métrique exige un 3ᵉ argument set des rares, calculé corpus-wide). +28 tests (tokenisation 8 cas, frequency 4 cas, extraction 4 cas, recall 10 cas avec multiplicité/casse/dégénérés, raccourci, **test propriété cas réaliste registre état civil** prouvant que rare-token recall discrimine plus que CER quand l'OCR rate les noms propres). **Verrou levé** : un bench BnF qui veut savoir « ce moteur préserve-t-il bien les noms de famille ? » a maintenant la métrique adaptée. Vue HTML « Worst lines + tokens rares manqués » suit Sprint 72 (chantier 1 d'A.I.1). |
|
| 211 |
| 70 | **Sprint 39 du plan d'évolution 2026 — Étape 4 / axe B : CLI pour piloter les pipelines composées sans Python**. Permet de spécifier une pipeline ou une comparaison de N pipelines dans un YAML déclaratif et de les exécuter via la CLI, sans écrire de Python. Nouveau module `picarones/core/pipeline_spec_loader.py` : `load_pipeline_spec_from_yaml/dict` parse YAML → `PipelineSpec` (steps avec dotted path module, args kwargs, inputs_from optionnel pour DAG branchant), `load_comparison_specs_from_yaml` retourne `(specs, extras)` pour comparaison. Import dynamique via `importlib`, validation stricte que la classe hérite de `BaseModule`. Exception `PipelineSpecLoadError` avec messages explicites pour 8 cas d'erreur. Nouveau sous-groupe CLI `picarones pipeline` : `run <spec.yaml> --corpus <dir>` (avec --output-json/--output-html/--lang) et `compare <specs.yaml> --corpus <dir>` (avec --output-html/--baseline). Le CLI lit `rankings` du YAML pour configurer la vue HTML comparative. **Aucun module métier ajouté** : le YAML référence des classes tierces que l'utilisateur a installées. +27 tests (resolve_class 5 cas, load_from_dict 9 cas, load_from_yaml 3 cas, load_comparison 2, CLI run 2, CLI compare 2, CLI help 3). **Verrou levé** : workflow BnF type — `picarones pipeline run my_pipeline.yaml --corpus ./scans --output-html rapport.html` — sans ingénieur Python dans la boucle. Spec versionnable en git pour la reproductibilité. |
|
| 212 |
| 69 | **Sprint 38 du plan d'évolution 2026 — Étape 4 / axe B : documentation utilisateur « Écrire un module pour le banc d'essai de pipelines »**. Premier guide pédagogique dédié à l'axe B. Nouveau document `docs/user/writing-a-pipeline-module.md` couvrant bout-en-bout : TL;DR avec exemple `MyCorrector` minimal, contrat `BaseModule` (tableau des champs + liste des `ArtifactType`), 3 exemples mockés explicitement étiquetés « pédagogique » (correcteur LLM TEXT→TEXT, reconstructeur TEXT→ALTO, classifieur TEXT→ENTITIES), orchestration mono-doc/corpus/comparaison/DAG branchant avec snippets exécutables (Sprints 63-66), génération de rapport HTML autonome (Sprints 67-68), bonnes pratiques (discipline des types, erreurs gracieuses, **pas de seuils éditoriaux dans votre module**), anti-patterns FAQ (« pourquoi pas de correcteur LLM intégré ? »…), tableau de référence rapide des sprints axe B. +34 tests anti-régression dans `test_sprint69_user_doc.py` (7 sections principales, 15 concepts API mentionnés, philosophie « banc d'essai pas atelier » + « aucun module métier » présente, références aux 6 sprints axe B + phase 0, ≥ 5 blocs Python + imports valides). **Verrou levé** : la barrière d'entrée pour un utilisateur tiers passe de « lire le code source des 6 sprints » à « lire un guide d'une page avec snippets copier-coller ». |
|
|
@@ -289,7 +290,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 289 |
## Contexte développement
|
| 290 |
|
| 291 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 292 |
-
- **Tests** :
|
| 293 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 294 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 295 |
- **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 |
+
| 72 | **Sprint 41 du plan d'évolution 2026 — A.I.1 chantier 1 : vue HTML « Worst lines globale » (clôture A.I.1)**. Suite directe Sprint 71 — la métrique rare-token recall est livrée, ce sprint livre la vue qui transcende les documents pour exposer les lignes individuelles les plus mal transcrites du corpus. Nouveau module `picarones/core/worst_lines.py` : dataclass `WorstLineEntry(rank, cer, engine_name, doc_id, line_index, gt_line, hyp_line, script_type)`, `extract_worst_lines(benchmark, top_n=20, engine_filter, script_type_filter)` collecte transversalement à tous les moteurs et docs, filtre par moteur et par strate (Sprint 45 doc_strata), trie par CER décroissant, retourne top_n avec rang 1-based. Récupère les textes GT/hyp par re-split du DocumentResult à l'index de ligne (limite : suppose BenchmarkResult non-compacté). Lignes CER=0 ignorées. Nouveau module `picarones/report/worst_lines_render.py` : `build_worst_lines_table_html(entries, labels)` server-side avec colonnes Rang/CER (gradient jaune→rouge)/Moteur/Doc/Ligne#/[Strate]/Diff GT→OCR. Colonne strate **adaptive** (omise si aucune entry n'en a). Diff caractère par caractère via `diff_utils.compute_char_diff` (Sprint 5), rouge barré pour suppressions, vert pour insertions. Anti-injection systématique. Retourne `""` si vide. +25 tests (extraction 5 cas, filtres 4 cas, edge cases 4 cas �� pas de line_metrics, vide, sans doc_strata, hyp plus courte —, rendu 8 cas, anti-injection 4 cas). **Verrou levé** : un chercheur qui voit `5% de mes lignes ont un CER > 0.42` dans le rapport peut désormais voir **quelles** lignes — diff inline, document parent, ligne#, moteur — pour comprendre ce qui casse. |
|
| 211 |
| 71 | **Sprint 40 du plan d'évolution 2026 — A.I.1 chantier 2 : rare-token recall (couche de calcul, démarrage de la résolution des critiques structurelles A.I)**. Premier sprint A.I qui s'attaque à la critique « la granularité ne s'arrête plus à la page ». Mesure le rappel sur les tokens rares (hapax + dis legomena, défaut `max_freq=2`) — répond à *« ce moteur préserve-t-il les noms propres rares qui m'intéressent pour l'indexation prosopographique ? »*. Nouveau module `picarones/core/rare_tokens.py` : `tokenize` Unicode-aware (contractions `L'an`/`d’une`, composés `peut-être`, apostrophe typographique `’` U+2019), `frequency_distribution(documents, case_sensitive)` → `{token: count}` corpus-wide, `extract_rare_tokens(documents, max_freq=2)` → `frozenset`, `compute_rare_token_recall(reference, hypothesis, rare_tokens)` retourne `{n_rare_tokens_in_reference, n_rare_tokens_recalled, recall, missed_tokens}` avec alignement bag-of-tokens multiplicitaire. **Pas d'enregistrement registre typé** (la métrique exige un 3ᵉ argument set des rares, calculé corpus-wide). +28 tests (tokenisation 8 cas, frequency 4 cas, extraction 4 cas, recall 10 cas avec multiplicité/casse/dégénérés, raccourci, **test propriété cas réaliste registre état civil** prouvant que rare-token recall discrimine plus que CER quand l'OCR rate les noms propres). **Verrou levé** : un bench BnF qui veut savoir « ce moteur préserve-t-il bien les noms de famille ? » a maintenant la métrique adaptée. Vue HTML « Worst lines + tokens rares manqués » suit Sprint 72 (chantier 1 d'A.I.1). |
|
| 212 |
| 70 | **Sprint 39 du plan d'évolution 2026 — Étape 4 / axe B : CLI pour piloter les pipelines composées sans Python**. Permet de spécifier une pipeline ou une comparaison de N pipelines dans un YAML déclaratif et de les exécuter via la CLI, sans écrire de Python. Nouveau module `picarones/core/pipeline_spec_loader.py` : `load_pipeline_spec_from_yaml/dict` parse YAML → `PipelineSpec` (steps avec dotted path module, args kwargs, inputs_from optionnel pour DAG branchant), `load_comparison_specs_from_yaml` retourne `(specs, extras)` pour comparaison. Import dynamique via `importlib`, validation stricte que la classe hérite de `BaseModule`. Exception `PipelineSpecLoadError` avec messages explicites pour 8 cas d'erreur. Nouveau sous-groupe CLI `picarones pipeline` : `run <spec.yaml> --corpus <dir>` (avec --output-json/--output-html/--lang) et `compare <specs.yaml> --corpus <dir>` (avec --output-html/--baseline). Le CLI lit `rankings` du YAML pour configurer la vue HTML comparative. **Aucun module métier ajouté** : le YAML référence des classes tierces que l'utilisateur a installées. +27 tests (resolve_class 5 cas, load_from_dict 9 cas, load_from_yaml 3 cas, load_comparison 2, CLI run 2, CLI compare 2, CLI help 3). **Verrou levé** : workflow BnF type — `picarones pipeline run my_pipeline.yaml --corpus ./scans --output-html rapport.html` — sans ingénieur Python dans la boucle. Spec versionnable en git pour la reproductibilité. |
|
| 213 |
| 69 | **Sprint 38 du plan d'évolution 2026 — Étape 4 / axe B : documentation utilisateur « Écrire un module pour le banc d'essai de pipelines »**. Premier guide pédagogique dédié à l'axe B. Nouveau document `docs/user/writing-a-pipeline-module.md` couvrant bout-en-bout : TL;DR avec exemple `MyCorrector` minimal, contrat `BaseModule` (tableau des champs + liste des `ArtifactType`), 3 exemples mockés explicitement étiquetés « pédagogique » (correcteur LLM TEXT→TEXT, reconstructeur TEXT→ALTO, classifieur TEXT→ENTITIES), orchestration mono-doc/corpus/comparaison/DAG branchant avec snippets exécutables (Sprints 63-66), génération de rapport HTML autonome (Sprints 67-68), bonnes pratiques (discipline des types, erreurs gracieuses, **pas de seuils éditoriaux dans votre module**), anti-patterns FAQ (« pourquoi pas de correcteur LLM intégré ? »…), tableau de référence rapide des sprints axe B. +34 tests anti-régression dans `test_sprint69_user_doc.py` (7 sections principales, 15 concepts API mentionnés, philosophie « banc d'essai pas atelier » + « aucun module métier » présente, références aux 6 sprints axe B + phase 0, ≥ 5 blocs Python + imports valides). **Verrou levé** : la barrière d'entrée pour un utilisateur tiers passe de « lire le code source des 6 sprints » à « lire un guide d'une page avec snippets copier-coller ». |
|
|
|
|
| 290 |
## Contexte développement
|
| 291 |
|
| 292 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 293 |
+
- **Tests** : 2548 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 — rare-token recall (chantier 2) + vue HTML Worst lines globale (chantier 1)**)
|
| 294 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 295 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 296 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extraction transversale des « Worst lines » du corpus — Sprint 72.
|
| 2 |
+
|
| 3 |
+
Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
Le percentile p95 du CER ligne (calculé par ``line_metrics.py``,
|
| 8 |
+
Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un
|
| 9 |
+
CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur
|
| 10 |
+
texte, leur diff, leur document parent, pour comprendre ce qui
|
| 11 |
+
casse.
|
| 12 |
+
|
| 13 |
+
Ce module fournit la requête transversale qui collecte, depuis un
|
| 14 |
+
``BenchmarkResult``, les **N lignes les plus mal transcrites de
|
| 15 |
+
tout le corpus**, classées par CER ligne. Filtrable par moteur
|
| 16 |
+
et par strate.
|
| 17 |
+
|
| 18 |
+
Limite documentée
|
| 19 |
+
-----------------
|
| 20 |
+
``DocumentResult.line_metrics`` ne stocke que les CER par ligne,
|
| 21 |
+
**pas le texte des lignes**. Pour récupérer les textes GT/hyp
|
| 22 |
+
on resplitte ``ground_truth`` et ``hypothesis`` du
|
| 23 |
+
``DocumentResult`` à l'index de la ligne. Cette logique
|
| 24 |
+
**suppose un BenchmarkResult non-compacté** — après ``compact()``
|
| 25 |
+
les textes sont tronqués à 200 caractères et les lignes au-delà
|
| 26 |
+
de cette troncature ne sont plus accessibles. En pratique on
|
| 27 |
+
extrait les worst lines **avant** la sérialisation/compactage.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import logging
|
| 33 |
+
from dataclasses import dataclass
|
| 34 |
+
from typing import Optional
|
| 35 |
+
|
| 36 |
+
logger = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class WorstLineEntry:
|
| 41 |
+
"""Une ligne du corpus identifiée comme mal transcrite.
|
| 42 |
+
|
| 43 |
+
Champs
|
| 44 |
+
------
|
| 45 |
+
rank:
|
| 46 |
+
Position dans le classement (1-based, 1 = pire CER).
|
| 47 |
+
cer:
|
| 48 |
+
CER de la ligne ∈ [0, 1].
|
| 49 |
+
engine_name:
|
| 50 |
+
Nom du moteur ayant produit cette hypothèse.
|
| 51 |
+
doc_id:
|
| 52 |
+
Identifiant du document parent.
|
| 53 |
+
line_index:
|
| 54 |
+
Index 0-based de la ligne dans le document GT.
|
| 55 |
+
gt_line:
|
| 56 |
+
Texte de la ligne dans la GT.
|
| 57 |
+
hyp_line:
|
| 58 |
+
Texte correspondant dans l'hypothèse (peut être ``""``
|
| 59 |
+
si l'OCR a sauté la ligne).
|
| 60 |
+
script_type:
|
| 61 |
+
Strate du document si disponible (``script_type``
|
| 62 |
+
capturé par le runner pour la stratification A.III).
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
rank: int
|
| 66 |
+
cer: float
|
| 67 |
+
engine_name: str
|
| 68 |
+
doc_id: str
|
| 69 |
+
line_index: int
|
| 70 |
+
gt_line: str
|
| 71 |
+
hyp_line: str
|
| 72 |
+
script_type: Optional[str] = None
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _split_lines(text: Optional[str]) -> list[str]:
|
| 76 |
+
"""Splitte un texte en lignes (cohérent avec ``line_metrics``).
|
| 77 |
+
|
| 78 |
+
Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les
|
| 79 |
+
lignes vides sont préservées. Retourne une liste vide si le
|
| 80 |
+
texte est None ou vide.
|
| 81 |
+
"""
|
| 82 |
+
if not text:
|
| 83 |
+
return []
|
| 84 |
+
# ``splitlines`` gère \r\n et \r correctement
|
| 85 |
+
return text.splitlines()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _line_at(text: Optional[str], index: int) -> str:
|
| 89 |
+
"""Retourne la ligne à l'index demandé, ou ``""`` si l'index
|
| 90 |
+
est hors borne (cas où l'OCR a moins de lignes que la GT)."""
|
| 91 |
+
lines = _split_lines(text)
|
| 92 |
+
if 0 <= index < len(lines):
|
| 93 |
+
return lines[index]
|
| 94 |
+
return ""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def extract_worst_lines(
|
| 98 |
+
benchmark,
|
| 99 |
+
*,
|
| 100 |
+
top_n: int = 20,
|
| 101 |
+
engine_filter: Optional[str] = None,
|
| 102 |
+
script_type_filter: Optional[str] = None,
|
| 103 |
+
) -> list[WorstLineEntry]:
|
| 104 |
+
"""Extrait les ``top_n`` lignes les plus mal transcrites du
|
| 105 |
+
corpus, transversalement à tous les moteurs et documents.
|
| 106 |
+
|
| 107 |
+
Parameters
|
| 108 |
+
----------
|
| 109 |
+
benchmark:
|
| 110 |
+
``BenchmarkResult`` non-compacté (cf. limite ci-dessus).
|
| 111 |
+
L'objet doit exposer ``engine_reports`` (liste de
|
| 112 |
+
``EngineReport``) et optionnellement ``doc_strata``
|
| 113 |
+
(map ``{doc_id: script_type}``, Sprint 45).
|
| 114 |
+
top_n:
|
| 115 |
+
Nombre de lignes à retourner. Défaut : 20.
|
| 116 |
+
engine_filter:
|
| 117 |
+
Si fourni, n'inclut que les lignes produites par ce moteur
|
| 118 |
+
(match exact sur ``engine_name``).
|
| 119 |
+
script_type_filter:
|
| 120 |
+
Si fourni, n'inclut que les lignes des documents de cette
|
| 121 |
+
strate (nécessite ``benchmark.doc_strata``).
|
| 122 |
+
|
| 123 |
+
Returns
|
| 124 |
+
-------
|
| 125 |
+
list[WorstLineEntry]
|
| 126 |
+
Liste triée par CER décroissant (pire en premier),
|
| 127 |
+
rang 1-based attribué après tri. Vide si aucune ligne
|
| 128 |
+
exploitable.
|
| 129 |
+
"""
|
| 130 |
+
if top_n <= 0:
|
| 131 |
+
return []
|
| 132 |
+
|
| 133 |
+
doc_strata = getattr(benchmark, "doc_strata", None) or {}
|
| 134 |
+
candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = []
|
| 135 |
+
|
| 136 |
+
for engine_report in getattr(benchmark, "engine_reports", []):
|
| 137 |
+
engine_name = engine_report.engine_name
|
| 138 |
+
if engine_filter is not None and engine_name != engine_filter:
|
| 139 |
+
continue
|
| 140 |
+
for dr in engine_report.document_results:
|
| 141 |
+
line_metrics = getattr(dr, "line_metrics", None)
|
| 142 |
+
if not line_metrics:
|
| 143 |
+
continue
|
| 144 |
+
cer_per_line = line_metrics.get("cer_per_line") if isinstance(
|
| 145 |
+
line_metrics, dict,
|
| 146 |
+
) else getattr(line_metrics, "cer_per_line", None)
|
| 147 |
+
if not cer_per_line:
|
| 148 |
+
continue
|
| 149 |
+
doc_id = dr.doc_id
|
| 150 |
+
doc_strata_value = doc_strata.get(doc_id)
|
| 151 |
+
if (
|
| 152 |
+
script_type_filter is not None
|
| 153 |
+
and doc_strata_value != script_type_filter
|
| 154 |
+
):
|
| 155 |
+
continue
|
| 156 |
+
for idx, cer in enumerate(cer_per_line):
|
| 157 |
+
if cer <= 0.0:
|
| 158 |
+
continue
|
| 159 |
+
gt_line = _line_at(dr.ground_truth, idx)
|
| 160 |
+
hyp_line = _line_at(dr.hypothesis, idx)
|
| 161 |
+
if not gt_line and not hyp_line:
|
| 162 |
+
continue
|
| 163 |
+
candidates.append((
|
| 164 |
+
float(cer), engine_name, doc_id, idx,
|
| 165 |
+
gt_line, hyp_line, doc_strata_value,
|
| 166 |
+
))
|
| 167 |
+
|
| 168 |
+
if not candidates:
|
| 169 |
+
return []
|
| 170 |
+
|
| 171 |
+
# Tri par CER décroissant ; en cas d'égalité, ordre stable
|
| 172 |
+
# (engine, doc_id, line_index) pour reproductibilité.
|
| 173 |
+
candidates.sort(
|
| 174 |
+
key=lambda c: (-c[0], c[1], c[2], c[3]),
|
| 175 |
+
)
|
| 176 |
+
selected = candidates[:top_n]
|
| 177 |
+
|
| 178 |
+
return [
|
| 179 |
+
WorstLineEntry(
|
| 180 |
+
rank=i + 1,
|
| 181 |
+
cer=cer,
|
| 182 |
+
engine_name=engine,
|
| 183 |
+
doc_id=doc_id,
|
| 184 |
+
line_index=line_index,
|
| 185 |
+
gt_line=gt_line,
|
| 186 |
+
hyp_line=hyp_line,
|
| 187 |
+
script_type=script_type,
|
| 188 |
+
)
|
| 189 |
+
for i, (
|
| 190 |
+
cer, engine, doc_id, line_index,
|
| 191 |
+
gt_line, hyp_line, script_type,
|
| 192 |
+
) in enumerate(selected)
|
| 193 |
+
]
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
__all__ = [
|
| 197 |
+
"WorstLineEntry",
|
| 198 |
+
"extract_worst_lines",
|
| 199 |
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML de la vue « Worst lines globale » — Sprint 72.
|
| 2 |
+
|
| 3 |
+
Suite directe de ``picarones/core/worst_lines.py`` (extraction
|
| 4 |
+
transversale). Pattern identique aux Sprints 41/43/62/67 : rendu
|
| 5 |
+
**server-side**, pas de JavaScript, anti-injection systématique
|
| 6 |
+
via ``html.escape``.
|
| 7 |
+
|
| 8 |
+
Vue distincte du tableau gallery existant
|
| 9 |
+
-----------------------------------------
|
| 10 |
+
La galerie OCR (vue ``view_gallery.html``) liste les documents
|
| 11 |
+
les plus problématiques. Cette vue va plus fin : elle liste les
|
| 12 |
+
**lignes individuelles** les plus problématiques, transversalement
|
| 13 |
+
à tous les documents et moteurs. Complémentaire, pas redondante.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from html import escape as _e
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
from picarones.core.worst_lines import WorstLineEntry
|
| 22 |
+
from picarones.report.diff_utils import compute_char_diff
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _color_for_cer(cer: float) -> str:
|
| 26 |
+
"""Gradient jaune → rouge : 0,3 jaune, 1,0 rouge profond."""
|
| 27 |
+
f = max(0.0, min(1.0, cer))
|
| 28 |
+
# Au-delà de 0,3 (seuil catastrophique courant), gradient
|
| 29 |
+
# jaune → rouge. En dessous, beige clair.
|
| 30 |
+
if f < 0.3:
|
| 31 |
+
return "#fff8dc"
|
| 32 |
+
ratio = (f - 0.3) / 0.7
|
| 33 |
+
r = int(240 + (200 - 240) * ratio)
|
| 34 |
+
g = int(220 + (60 - 220) * ratio)
|
| 35 |
+
b = int(120 + (60 - 120) * ratio)
|
| 36 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _render_diff_inline(reference: str, hypothesis: str) -> str:
|
| 40 |
+
"""Rendu HTML inline d'un diff caractère par caractère.
|
| 41 |
+
|
| 42 |
+
- ``equal`` → texte normal
|
| 43 |
+
- ``delete`` → fond rouge clair, barré (manquait dans hyp)
|
| 44 |
+
- ``insert`` → fond vert clair (ajouté par hyp)
|
| 45 |
+
- ``replace`` → fond rouge clair barré + fond vert clair pour
|
| 46 |
+
la nouvelle valeur (côte à côte)
|
| 47 |
+
"""
|
| 48 |
+
if not reference and not hypothesis:
|
| 49 |
+
return '<span style="opacity:.5">∅</span>'
|
| 50 |
+
ops = compute_char_diff(reference or "", hypothesis or "")
|
| 51 |
+
parts: list[str] = []
|
| 52 |
+
for op in ops:
|
| 53 |
+
kind = op["op"]
|
| 54 |
+
if kind == "equal":
|
| 55 |
+
parts.append(_e(op["text"]))
|
| 56 |
+
elif kind == "delete":
|
| 57 |
+
parts.append(
|
| 58 |
+
f'<span style="background:#fdd;text-decoration:line-through">'
|
| 59 |
+
f'{_e(op["text"])}</span>'
|
| 60 |
+
)
|
| 61 |
+
elif kind == "insert":
|
| 62 |
+
parts.append(
|
| 63 |
+
f'<span style="background:#dfd">{_e(op["text"])}</span>'
|
| 64 |
+
)
|
| 65 |
+
elif kind == "replace":
|
| 66 |
+
parts.append(
|
| 67 |
+
f'<span style="background:#fdd;text-decoration:line-through">'
|
| 68 |
+
f'{_e(op["old"])}</span>'
|
| 69 |
+
f'<span style="background:#dfd">{_e(op["new"])}</span>'
|
| 70 |
+
)
|
| 71 |
+
return "".join(parts)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def build_worst_lines_table_html(
|
| 75 |
+
entries: list[WorstLineEntry],
|
| 76 |
+
labels: Optional[dict[str, str]] = None,
|
| 77 |
+
) -> str:
|
| 78 |
+
"""Construit le tableau HTML des worst lines.
|
| 79 |
+
|
| 80 |
+
Retourne ``""`` si la liste est vide. Adaptive : si aucune
|
| 81 |
+
entrée n'a de ``script_type``, la colonne strate est omise.
|
| 82 |
+
"""
|
| 83 |
+
if not entries:
|
| 84 |
+
return ""
|
| 85 |
+
labels = labels or {}
|
| 86 |
+
title = labels.get("worst_lines_title", "Lignes les plus problématiques")
|
| 87 |
+
note = labels.get(
|
| 88 |
+
"worst_lines_note",
|
| 89 |
+
"Top-N lignes du corpus classées par CER ligne décroissant. "
|
| 90 |
+
"Diff caractère par caractère : rouge barré = manquant dans "
|
| 91 |
+
"l'OCR, vert = ajouté par l'OCR.",
|
| 92 |
+
)
|
| 93 |
+
rank_label = labels.get("worst_lines_rank_label", "Rang")
|
| 94 |
+
cer_label = labels.get("worst_lines_cer_label", "CER")
|
| 95 |
+
engine_label = labels.get("worst_lines_engine_label", "Moteur")
|
| 96 |
+
doc_label = labels.get("worst_lines_doc_label", "Document")
|
| 97 |
+
line_label = labels.get("worst_lines_line_label", "Ligne #")
|
| 98 |
+
strata_label = labels.get("worst_lines_strata_label", "Strate")
|
| 99 |
+
diff_label = labels.get("worst_lines_diff_label", "GT → OCR (diff)")
|
| 100 |
+
|
| 101 |
+
has_strata = any(e.script_type for e in entries)
|
| 102 |
+
|
| 103 |
+
parts = [
|
| 104 |
+
'<div class="worst-lines" style="margin:1rem 0">',
|
| 105 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 106 |
+
f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
|
| 107 |
+
f'{_e(note)}</div>',
|
| 108 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 109 |
+
'font-size:.85rem">',
|
| 110 |
+
'<thead><tr>',
|
| 111 |
+
]
|
| 112 |
+
cols = [rank_label, cer_label, engine_label, doc_label, line_label]
|
| 113 |
+
if has_strata:
|
| 114 |
+
cols.append(strata_label)
|
| 115 |
+
cols.append(diff_label)
|
| 116 |
+
for col in cols:
|
| 117 |
+
parts.append(
|
| 118 |
+
f'<th style="padding:.3rem .5rem;text-align:left;'
|
| 119 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 120 |
+
f'{_e(col)}</th>'
|
| 121 |
+
)
|
| 122 |
+
parts.append("</tr></thead><tbody>")
|
| 123 |
+
for entry in entries:
|
| 124 |
+
cer_color = _color_for_cer(entry.cer)
|
| 125 |
+
parts.append("<tr>")
|
| 126 |
+
parts.append(
|
| 127 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 128 |
+
f'font-weight:600">{entry.rank}</td>'
|
| 129 |
+
)
|
| 130 |
+
parts.append(
|
| 131 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 132 |
+
f'background:{cer_color};font-family:monospace">'
|
| 133 |
+
f'{entry.cer * 100:.1f}%</td>'
|
| 134 |
+
)
|
| 135 |
+
parts.append(
|
| 136 |
+
f'<td style="padding:.3rem .5rem">{_e(entry.engine_name)}</td>'
|
| 137 |
+
)
|
| 138 |
+
parts.append(
|
| 139 |
+
f'<td style="padding:.3rem .5rem;font-family:monospace;'
|
| 140 |
+
f'font-size:.8rem">{_e(entry.doc_id)}</td>'
|
| 141 |
+
)
|
| 142 |
+
parts.append(
|
| 143 |
+
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 144 |
+
f'{entry.line_index}</td>'
|
| 145 |
+
)
|
| 146 |
+
if has_strata:
|
| 147 |
+
parts.append(
|
| 148 |
+
f'<td style="padding:.3rem .5rem;font-size:.8rem">'
|
| 149 |
+
f'{_e(entry.script_type or "—")}</td>'
|
| 150 |
+
)
|
| 151 |
+
parts.append(
|
| 152 |
+
f'<td style="padding:.3rem .5rem;font-family:monospace;'
|
| 153 |
+
f'font-size:.85rem">'
|
| 154 |
+
f'{_render_diff_inline(entry.gt_line, entry.hyp_line)}</td>'
|
| 155 |
+
)
|
| 156 |
+
parts.append("</tr>")
|
| 157 |
+
parts.append("</tbody></table></div>")
|
| 158 |
+
return "".join(parts)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
__all__ = [
|
| 162 |
+
"build_worst_lines_table_html",
|
| 163 |
+
]
|
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 72 — A.I.1 chantier 1 : vue « Worst lines globale ».
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``extract_worst_lines`` :
|
| 6 |
+
- Top-N respecté, tri par CER décroissant
|
| 7 |
+
- Filtre par moteur
|
| 8 |
+
- Filtre par strate (``script_type``)
|
| 9 |
+
- Lignes avec CER == 0 ignorées
|
| 10 |
+
- DocumentResult sans ``line_metrics`` ignoré
|
| 11 |
+
- Index de ligne hors borne → texte vide mais entrée incluse
|
| 12 |
+
si au moins l'un des deux côtés a du texte
|
| 13 |
+
- top_n=0 → liste vide
|
| 14 |
+
2. ``WorstLineEntry`` : rang attribué après tri (1-based).
|
| 15 |
+
3. ``build_worst_lines_table_html`` :
|
| 16 |
+
- Tableau rendu avec colonnes attendues
|
| 17 |
+
- Chaîne vide si entries vide
|
| 18 |
+
- Colonne strate omise si aucune entry n'a script_type
|
| 19 |
+
- Cellule CER colorée
|
| 20 |
+
- Diff GT/hyp rendu (rouge barré + vert)
|
| 21 |
+
4. Anti-injection : nom moteur, doc_id, ligne GT/hyp avec
|
| 22 |
+
``<script>`` correctement échappés.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from dataclasses import dataclass, field
|
| 28 |
+
from typing import Any
|
| 29 |
+
|
| 30 |
+
from picarones.core.worst_lines import WorstLineEntry, extract_worst_lines
|
| 31 |
+
from picarones.report.worst_lines_render import build_worst_lines_table_html
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 35 |
+
# Mocks pour BenchmarkResult / EngineReport / DocumentResult
|
| 36 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 37 |
+
# On évite les vrais dataclasses du runner (lourds, dépendances) pour
|
| 38 |
+
# garder les tests focalisés sur la logique d'extraction.
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class _DocResult:
|
| 43 |
+
doc_id: str
|
| 44 |
+
ground_truth: str
|
| 45 |
+
hypothesis: str
|
| 46 |
+
line_metrics: dict[str, Any] | None = None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class _EngineReport:
|
| 51 |
+
engine_name: str
|
| 52 |
+
document_results: list[_DocResult] = field(default_factory=list)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class _Benchmark:
|
| 57 |
+
engine_reports: list[_EngineReport] = field(default_factory=list)
|
| 58 |
+
doc_strata: dict[str, str] | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _make_benchmark() -> _Benchmark:
|
| 62 |
+
"""Construit un benchmark de test : 2 moteurs × 3 docs."""
|
| 63 |
+
bench = _Benchmark(doc_strata={"d0": "imprime", "d1": "manuscrit", "d2": "manuscrit"})
|
| 64 |
+
for engine_name, cer_offsets in (("tess", 0.0), ("pero", 0.1)):
|
| 65 |
+
docs = []
|
| 66 |
+
for doc_id, gt, hyp, cer_lines in (
|
| 67 |
+
("d0", "ligne0\nligne1\nligne2", "ligne0\nlignE1\nligne2",
|
| 68 |
+
[0.0, 0.2, 0.0]),
|
| 69 |
+
("d1", "abc\ndef\nghi", "abc\nXXX\nghi",
|
| 70 |
+
[0.0, 1.0, 0.0]),
|
| 71 |
+
("d2", "alpha\nbeta\ngamma", "alpha\nbeta\nXXXXX",
|
| 72 |
+
[0.0, 0.0, 0.7]),
|
| 73 |
+
):
|
| 74 |
+
docs.append(_DocResult(
|
| 75 |
+
doc_id=doc_id,
|
| 76 |
+
ground_truth=gt,
|
| 77 |
+
hypothesis=hyp,
|
| 78 |
+
line_metrics={
|
| 79 |
+
"cer_per_line": [c + cer_offsets for c in cer_lines],
|
| 80 |
+
},
|
| 81 |
+
))
|
| 82 |
+
bench.engine_reports.append(
|
| 83 |
+
_EngineReport(engine_name=engine_name, document_results=docs),
|
| 84 |
+
)
|
| 85 |
+
return bench
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 89 |
+
# 1. extract_worst_lines
|
| 90 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
class TestExtractBasic:
|
| 94 |
+
def test_top_n_respected(self) -> None:
|
| 95 |
+
bench = _make_benchmark()
|
| 96 |
+
out = extract_worst_lines(bench, top_n=3)
|
| 97 |
+
assert len(out) == 3
|
| 98 |
+
|
| 99 |
+
def test_sorted_by_cer_desc(self) -> None:
|
| 100 |
+
bench = _make_benchmark()
|
| 101 |
+
out = extract_worst_lines(bench, top_n=20)
|
| 102 |
+
cers = [e.cer for e in out]
|
| 103 |
+
assert cers == sorted(cers, reverse=True)
|
| 104 |
+
|
| 105 |
+
def test_rank_is_1_based(self) -> None:
|
| 106 |
+
bench = _make_benchmark()
|
| 107 |
+
out = extract_worst_lines(bench, top_n=5)
|
| 108 |
+
ranks = [e.rank for e in out]
|
| 109 |
+
assert ranks == list(range(1, len(out) + 1))
|
| 110 |
+
|
| 111 |
+
def test_top_n_zero_returns_empty(self) -> None:
|
| 112 |
+
bench = _make_benchmark()
|
| 113 |
+
assert extract_worst_lines(bench, top_n=0) == []
|
| 114 |
+
|
| 115 |
+
def test_lines_with_zero_cer_ignored(self) -> None:
|
| 116 |
+
bench = _make_benchmark()
|
| 117 |
+
out = extract_worst_lines(bench, top_n=100)
|
| 118 |
+
for entry in out:
|
| 119 |
+
assert entry.cer > 0.0
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class TestFilters:
|
| 123 |
+
def test_engine_filter(self) -> None:
|
| 124 |
+
bench = _make_benchmark()
|
| 125 |
+
out = extract_worst_lines(bench, top_n=20, engine_filter="pero")
|
| 126 |
+
assert all(e.engine_name == "pero" for e in out)
|
| 127 |
+
assert len(out) > 0
|
| 128 |
+
|
| 129 |
+
def test_engine_filter_unknown_engine(self) -> None:
|
| 130 |
+
bench = _make_benchmark()
|
| 131 |
+
out = extract_worst_lines(
|
| 132 |
+
bench, top_n=20, engine_filter="non_existing",
|
| 133 |
+
)
|
| 134 |
+
assert out == []
|
| 135 |
+
|
| 136 |
+
def test_strata_filter(self) -> None:
|
| 137 |
+
bench = _make_benchmark()
|
| 138 |
+
out = extract_worst_lines(
|
| 139 |
+
bench, top_n=20, script_type_filter="manuscrit",
|
| 140 |
+
)
|
| 141 |
+
assert all(e.script_type == "manuscrit" for e in out)
|
| 142 |
+
assert len(out) > 0
|
| 143 |
+
|
| 144 |
+
def test_strata_filter_unknown_strata(self) -> None:
|
| 145 |
+
bench = _make_benchmark()
|
| 146 |
+
out = extract_worst_lines(
|
| 147 |
+
bench, top_n=20, script_type_filter="non_existing",
|
| 148 |
+
)
|
| 149 |
+
assert out == []
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class TestEdgeCases:
|
| 153 |
+
def test_no_line_metrics(self) -> None:
|
| 154 |
+
bench = _Benchmark(engine_reports=[
|
| 155 |
+
_EngineReport(engine_name="x", document_results=[
|
| 156 |
+
_DocResult(doc_id="d", ground_truth="x", hypothesis="x",
|
| 157 |
+
line_metrics=None),
|
| 158 |
+
]),
|
| 159 |
+
])
|
| 160 |
+
assert extract_worst_lines(bench) == []
|
| 161 |
+
|
| 162 |
+
def test_empty_engine_reports(self) -> None:
|
| 163 |
+
bench = _Benchmark()
|
| 164 |
+
assert extract_worst_lines(bench) == []
|
| 165 |
+
|
| 166 |
+
def test_no_doc_strata_attribute(self) -> None:
|
| 167 |
+
# benchmark sans attribut doc_strata → pas de filtre strata
|
| 168 |
+
# mais l'extraction fonctionne
|
| 169 |
+
bench = _Benchmark(engine_reports=[
|
| 170 |
+
_EngineReport(engine_name="x", document_results=[
|
| 171 |
+
_DocResult(
|
| 172 |
+
doc_id="d", ground_truth="abc", hypothesis="aXc",
|
| 173 |
+
line_metrics={"cer_per_line": [0.5]},
|
| 174 |
+
),
|
| 175 |
+
]),
|
| 176 |
+
])
|
| 177 |
+
out = extract_worst_lines(bench, top_n=5)
|
| 178 |
+
assert len(out) == 1
|
| 179 |
+
assert out[0].script_type is None
|
| 180 |
+
|
| 181 |
+
def test_hyp_shorter_than_gt(self) -> None:
|
| 182 |
+
# Hyp a moins de lignes que GT — ligne en trop dans GT
|
| 183 |
+
# est récupérée avec hyp_line=""
|
| 184 |
+
bench = _Benchmark(engine_reports=[
|
| 185 |
+
_EngineReport(engine_name="x", document_results=[
|
| 186 |
+
_DocResult(
|
| 187 |
+
doc_id="d", ground_truth="abc\ndef\nghi",
|
| 188 |
+
hypothesis="abc", # 1 ligne seulement
|
| 189 |
+
line_metrics={"cer_per_line": [0.0, 1.0, 1.0]},
|
| 190 |
+
),
|
| 191 |
+
]),
|
| 192 |
+
])
|
| 193 |
+
out = extract_worst_lines(bench, top_n=5)
|
| 194 |
+
assert len(out) == 2 # lignes 1 et 2 avec CER = 1.0
|
| 195 |
+
for entry in out:
|
| 196 |
+
assert entry.hyp_line == ""
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 200 |
+
# 2. build_worst_lines_table_html
|
| 201 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
class TestRender:
|
| 205 |
+
def _sample_entries(self) -> list[WorstLineEntry]:
|
| 206 |
+
return [
|
| 207 |
+
WorstLineEntry(
|
| 208 |
+
rank=1, cer=0.95, engine_name="tess", doc_id="d1",
|
| 209 |
+
line_index=2, gt_line="bonjour le monde",
|
| 210 |
+
hyp_line="bnjour 1e mnde", script_type="imprime",
|
| 211 |
+
),
|
| 212 |
+
WorstLineEntry(
|
| 213 |
+
rank=2, cer=0.42, engine_name="pero", doc_id="d3",
|
| 214 |
+
line_index=0, gt_line="hello world",
|
| 215 |
+
hyp_line="hello wOrld", script_type="manuscrit",
|
| 216 |
+
),
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
def test_renders_table(self) -> None:
|
| 220 |
+
html = build_worst_lines_table_html(self._sample_entries())
|
| 221 |
+
assert "<table" in html
|
| 222 |
+
assert "tess" in html
|
| 223 |
+
assert "pero" in html
|
| 224 |
+
assert "d1" in html
|
| 225 |
+
assert "d3" in html
|
| 226 |
+
|
| 227 |
+
def test_empty_returns_empty(self) -> None:
|
| 228 |
+
assert build_worst_lines_table_html([]) == ""
|
| 229 |
+
|
| 230 |
+
def test_columns_present(self) -> None:
|
| 231 |
+
html = build_worst_lines_table_html(self._sample_entries())
|
| 232 |
+
for col in ("Rang", "CER", "Moteur", "Document", "Ligne"):
|
| 233 |
+
assert col in html
|
| 234 |
+
|
| 235 |
+
def test_strata_column_when_present(self) -> None:
|
| 236 |
+
html = build_worst_lines_table_html(self._sample_entries())
|
| 237 |
+
assert "Strate" in html
|
| 238 |
+
assert "imprime" in html
|
| 239 |
+
assert "manuscrit" in html
|
| 240 |
+
|
| 241 |
+
def test_strata_column_omitted_when_absent(self) -> None:
|
| 242 |
+
entries = [
|
| 243 |
+
WorstLineEntry(
|
| 244 |
+
rank=1, cer=0.5, engine_name="t", doc_id="d", line_index=0,
|
| 245 |
+
gt_line="abc", hyp_line="aXc", script_type=None,
|
| 246 |
+
),
|
| 247 |
+
]
|
| 248 |
+
html = build_worst_lines_table_html(entries)
|
| 249 |
+
assert "Strate" not in html
|
| 250 |
+
|
| 251 |
+
def test_cer_cell_colored(self) -> None:
|
| 252 |
+
html = build_worst_lines_table_html(self._sample_entries())
|
| 253 |
+
assert "background:#" in html
|
| 254 |
+
|
| 255 |
+
def test_diff_rendered(self) -> None:
|
| 256 |
+
html = build_worst_lines_table_html(self._sample_entries())
|
| 257 |
+
# Diff inline : couleurs rouge clair pour suppressions, vert pour insertions
|
| 258 |
+
assert "#fdd" in html
|
| 259 |
+
assert "#dfd" in html
|
| 260 |
+
|
| 261 |
+
def test_cer_displayed_as_percent(self) -> None:
|
| 262 |
+
html = build_worst_lines_table_html(self._sample_entries())
|
| 263 |
+
assert "95.0%" in html
|
| 264 |
+
assert "42.0%" in html
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 268 |
+
# 3. Anti-injection
|
| 269 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
class TestAntiInjection:
|
| 273 |
+
def test_engine_name_escaped(self) -> None:
|
| 274 |
+
entries = [
|
| 275 |
+
WorstLineEntry(
|
| 276 |
+
rank=1, cer=0.5, engine_name="<script>alert(1)</script>",
|
| 277 |
+
doc_id="d", line_index=0,
|
| 278 |
+
gt_line="abc", hyp_line="aXc",
|
| 279 |
+
),
|
| 280 |
+
]
|
| 281 |
+
html = build_worst_lines_table_html(entries)
|
| 282 |
+
assert "<script>" not in html
|
| 283 |
+
assert "<script>" in html
|
| 284 |
+
|
| 285 |
+
def test_doc_id_escaped(self) -> None:
|
| 286 |
+
entries = [
|
| 287 |
+
WorstLineEntry(
|
| 288 |
+
rank=1, cer=0.5, engine_name="t",
|
| 289 |
+
doc_id="<img src=x>", line_index=0,
|
| 290 |
+
gt_line="abc", hyp_line="aXc",
|
| 291 |
+
),
|
| 292 |
+
]
|
| 293 |
+
html = build_worst_lines_table_html(entries)
|
| 294 |
+
assert "<img src=x>" not in html
|
| 295 |
+
assert "<img" in html
|
| 296 |
+
|
| 297 |
+
def test_gt_line_escaped(self) -> None:
|
| 298 |
+
entries = [
|
| 299 |
+
WorstLineEntry(
|
| 300 |
+
rank=1, cer=0.5, engine_name="t", doc_id="d", line_index=0,
|
| 301 |
+
gt_line="<b>HACK</b>", hyp_line="bonjour",
|
| 302 |
+
),
|
| 303 |
+
]
|
| 304 |
+
html = build_worst_lines_table_html(entries)
|
| 305 |
+
# La balise brute ne doit pas être présente. Le diff
|
| 306 |
+
# caractère-par-caractère peut splitter ``<b>`` en chunks
|
| 307 |
+
# séparés mais chaque chunk est échappé.
|
| 308 |
+
assert "<b>HACK</b>" not in html
|
| 309 |
+
assert "<" in html
|
| 310 |
+
assert ">" in html
|
| 311 |
+
|
| 312 |
+
def test_label_via_i18n_escaped(self) -> None:
|
| 313 |
+
entries = [
|
| 314 |
+
WorstLineEntry(
|
| 315 |
+
rank=1, cer=0.5, engine_name="t", doc_id="d", line_index=0,
|
| 316 |
+
gt_line="abc", hyp_line="aXc",
|
| 317 |
+
),
|
| 318 |
+
]
|
| 319 |
+
labels = {"worst_lines_title": "<b>X</b>"}
|
| 320 |
+
html = build_worst_lines_table_html(entries, labels=labels)
|
| 321 |
+
assert "<b>X</b>" not in html
|
| 322 |
+
assert "<b>" in html
|