Spaces:
Running
sprint74: encart HTML « Ce corpus est-il habituel ? » (A.I.3 chantier 1)
Browse filesSuite directe Sprint 73 — clôture A.I.3. Vue HTML server-side
(pas de JS) qui place la difficulté du corpus courant dans la
distribution des corpus précédents stockés en SQLite (Sprint 8).
- Nouveau module picarones/report/baseline_render.py :
- build_corpus_difficulty_baseline_html : phrase factuelle +
boxplot SVG avec phrase template auto-sélectionnée selon
harder_than_usual/easier_than_usual/usual.
- _build_difficulty_boxplot_svg server-side : moustache min→max,
boîte Q1→Q3, médiane, point courant coloré adaptive (bleu si
facile, rouge si difficile, vert sinon), étiquettes
numériques, accessible (role/aria-label).
- _quantiles méthode inclusive gère N=0/1.
- Adaptive : "" si percentile_data None, boxplot omis si pas
d'historical_values.
- +4 clés i18n FR/EN avec templates Python.
- +20 tests dans test_sprint74_baseline_html.py.
A.I.3 livré bout-en-bout (Sprint 73 calc + narrative + Sprint 74
vue HTML).
Tests : 2589 passed, 2 skipped, 0 failed.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +56 -0
- CLAUDE.md +2 -1
- picarones/report/baseline_render.py +238 -0
- picarones/report/i18n/en.json +5 -1
- picarones/report/i18n/fr.json +5 -1
- tests/test_sprint74_baseline_html.py +235 -0
|
@@ -16,6 +16,62 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 73 — A.I.3 chantier 2 : détecteur narratif
|
| 20 |
``engine_off_baseline`` (couche calcul + narrative).** L'historique
|
| 21 |
SQLite (Sprint 8) existait depuis longtemps mais aucun détecteur
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 74 — A.I.3 chantier 1 : encart « Ce corpus est-il
|
| 20 |
+
habituel ? » (clôture A.I.3).** Suite directe Sprint 73
|
| 21 |
+
(couche calcul + détecteur narratif). Ce sprint livre le
|
| 22 |
+
rendu HTML de l'encart qui place la difficulté du corpus
|
| 23 |
+
courant dans la distribution des corpus précédents stockés
|
| 24 |
+
en SQLite (Sprint 8) — phrase factuelle + mini-boxplot SVG.
|
| 25 |
+
- Nouveau module `picarones/report/baseline_render.py` :
|
| 26 |
+
- ``build_corpus_difficulty_baseline_html(percentile_data,
|
| 27 |
+
historical_values, labels)`` produit l'encart complet
|
| 28 |
+
(titre + phrase factuelle + boxplot SVG si valeurs
|
| 29 |
+
fournies). Phrase template auto-sélectionnée selon les
|
| 30 |
+
flags ``harder_than_usual`` / ``easier_than_usual`` /
|
| 31 |
+
« usual » du percentile_data.
|
| 32 |
+
- ``_build_difficulty_boxplot_svg(historical_values,
|
| 33 |
+
current, width, height)`` construit un boxplot horizontal
|
| 34 |
+
SVG **server-side** (pas de JavaScript) avec :
|
| 35 |
+
- moustache min → max (ligne grise)
|
| 36 |
+
- boîte Q1 → Q3 (rectangle gris clair)
|
| 37 |
+
- médiane (trait noir épais)
|
| 38 |
+
- point courant (cercle coloré)
|
| 39 |
+
- **Couleur du point courant adaptive** :
|
| 40 |
+
- bleu (#3b87d8) si current < Q1 (corpus plus facile que
|
| 41 |
+
d'habitude)
|
| 42 |
+
- rouge (#d8553b) si current > Q3 (plus difficile)
|
| 43 |
+
- vert (#5fa860) sinon (habituel)
|
| 44 |
+
- Étiquettes numériques min / max / current visibles (fonts
|
| 45 |
+
explicites).
|
| 46 |
+
- SVG accessible : ``role="img"`` + ``aria-label``.
|
| 47 |
+
- Adaptive : retourne ``""`` si ``percentile_data is None``
|
| 48 |
+
(rapport adaptatif). Si ``historical_values`` vide /
|
| 49 |
+
``None``, seule la phrase factuelle est rendue (le boxplot
|
| 50 |
+
est omis silencieusement).
|
| 51 |
+
- Helper interne ``_quantiles(values)`` calcule
|
| 52 |
+
(min, Q1, median, Q3, max) avec méthode inclusive — gère le
|
| 53 |
+
cas N=0 et N=1.
|
| 54 |
+
- +4 clés i18n FR/EN (``baseline_corpus_title``,
|
| 55 |
+
``baseline_corpus_harder``, ``baseline_corpus_easier``,
|
| 56 |
+
``baseline_corpus_usual``). Templates Python avec
|
| 57 |
+
placeholders ``{current:.2f}``, ``{percentile:.0f}``,
|
| 58 |
+
``{n_runs}``.
|
| 59 |
+
- +20 tests dans `test_sprint74_baseline_html.py` :
|
| 60 |
+
- ``_quantiles`` (3 cas — simple, vide, single)
|
| 61 |
+
- SVG (8 cas — bien formé, vide, couleurs harder/easier/usual,
|
| 62 |
+
box+moustaches+cercle, dégénéré tous identiques, current
|
| 63 |
+
hors range historique)
|
| 64 |
+
- HTML (6 cas — None, harder/easier/usual, SVG omis sans
|
| 65 |
+
values, SVG présent avec values)
|
| 66 |
+
- anti-injection sur label i18n
|
| 67 |
+
- complétude i18n FR + EN
|
| 68 |
+
- **Verrou levé** : un benchmark BnF avec un historique SQLite
|
| 69 |
+
chargé peut désormais générer en tête de rapport un encart
|
| 70 |
+
qui dit *« ce corpus est plus difficile que la moyenne — au
|
| 71 |
+
88ᵉ percentile des 47 corpus précédents »* avec un boxplot
|
| 72 |
+
qui le visualise. L'A.I.3 est livré bout-en-bout (Sprint 73
|
| 73 |
+
couche calcul + détecteur, Sprint 74 vue HTML).
|
| 74 |
+
|
| 75 |
- **Sprint 73 — A.I.3 chantier 2 : détecteur narratif
|
| 76 |
``engine_off_baseline`` (couche calcul + narrative).** L'historique
|
| 77 |
SQLite (Sprint 8) existait depuis longtemps mais aucun détecteur
|
|
@@ -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 |
| 73 | **Sprint 42 du plan d'évolution 2026 — A.I.3 chantier 2 : détecteur narratif `engine_off_baseline` (couche calcul + narrative)**. L'historique SQLite (Sprint 8) existait mais aucun détecteur narratif ne le lisait. Répond à « comment ce moteur se comporte-t-il sur ce corpus par rapport à ses runs précédents de mon institution ? ». L'encart HTML « Ce corpus est-il habituel ? » (chantier 1, boxplot SVG) suit Sprint 74. Nouveau module `picarones/core/baseline_comparison.py` : `compute_engine_baseline(history, engine_name, corpus_name, current_cer, current_run_id, min_runs=5, relative_delta_threshold=0.20)` filtre apple-to-apple par moteur×corpus, exclut le run courant si fourni, retourne dict avec cer_current/historical_mean/median, n_runs, absolute_delta, relative_delta, off_baseline ; `compute_corpus_difficulty_percentile` place la difficulté courante dans la distribution historique (lit metadata.difficulty), flags harder/easier_than_usual (P75/P25). Nouveau `FactType.ENGINE_OFF_BASELINE` + détecteur `detect_engine_off_baseline` (priority 150) qui émet 1 Fact par moteur off_baseline, importance HIGH si |delta|≥50% sinon MEDIUM, silencieux si baseline_comparisons absent/vide ou relative_delta=None. Templates FR/EN. +21 tests : couche calcul (9 cas dont min_runs/current_run_id/baseline=0/CER None), percentile (4 cas), détecteur (6 cas), **traçabilité anti-hallucination FR+EN** (chaque nombre rendu traçable au payload). **Verrou levé** : un bench BnF qui pousse ses résultats dans l'historique voit dans la synthèse « ce moteur a un CER inhabituel sur ce corpus par rapport à vos 12 runs précédents ». |
|
| 211 |
| 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. |
|
| 212 |
| 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). |
|
|
@@ -291,7 +292,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 291 |
## Contexte développement
|
| 292 |
|
| 293 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 294 |
-
- **Tests** :
|
| 295 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 296 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 297 |
- **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 |
+
| 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). |
|
| 211 |
| 73 | **Sprint 42 du plan d'évolution 2026 — A.I.3 chantier 2 : détecteur narratif `engine_off_baseline` (couche calcul + narrative)**. L'historique SQLite (Sprint 8) existait mais aucun détecteur narratif ne le lisait. Répond à « comment ce moteur se comporte-t-il sur ce corpus par rapport à ses runs précédents de mon institution ? ». L'encart HTML « Ce corpus est-il habituel ? » (chantier 1, boxplot SVG) suit Sprint 74. Nouveau module `picarones/core/baseline_comparison.py` : `compute_engine_baseline(history, engine_name, corpus_name, current_cer, current_run_id, min_runs=5, relative_delta_threshold=0.20)` filtre apple-to-apple par moteur×corpus, exclut le run courant si fourni, retourne dict avec cer_current/historical_mean/median, n_runs, absolute_delta, relative_delta, off_baseline ; `compute_corpus_difficulty_percentile` place la difficulté courante dans la distribution historique (lit metadata.difficulty), flags harder/easier_than_usual (P75/P25). Nouveau `FactType.ENGINE_OFF_BASELINE` + détecteur `detect_engine_off_baseline` (priority 150) qui émet 1 Fact par moteur off_baseline, importance HIGH si |delta|≥50% sinon MEDIUM, silencieux si baseline_comparisons absent/vide ou relative_delta=None. Templates FR/EN. +21 tests : couche calcul (9 cas dont min_runs/current_run_id/baseline=0/CER None), percentile (4 cas), détecteur (6 cas), **traçabilité anti-hallucination FR+EN** (chaque nombre rendu traçable au payload). **Verrou levé** : un bench BnF qui pousse ses résultats dans l'historique voit dans la synthèse « ce moteur a un CER inhabituel sur ce corpus par rapport à vos 12 runs précédents ». |
|
| 212 |
| 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. |
|
| 213 |
| 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). |
|
|
|
|
| 292 |
## Contexte développement
|
| 293 |
|
| 294 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 295 |
+
- **Tests** : 2589 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 + vue HTML Worst lines globale ; **Sprints 73-74 = A.I.3 livré bout-en-bout — détecteur narratif engine_off_baseline + encart HTML « Ce corpus est-il habituel ? » avec boxplot SVG**)
|
| 296 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 297 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 298 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu de l'encart « Ce corpus est-il habituel ? » — Sprint 74.
|
| 2 |
+
|
| 3 |
+
A.I.3 chantier 1 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Suite directe Sprint 73 (couche calcul + détecteur narratif). Ce
|
| 6 |
+
sprint livre le rendu HTML qui place la difficulté du corpus
|
| 7 |
+
courant dans la distribution des corpus précédents stockés en
|
| 8 |
+
SQLite (Sprint 8) — un mini-boxplot horizontal en SVG avec un
|
| 9 |
+
point pour la position du corpus courant, accompagné d'une phrase
|
| 10 |
+
factuelle.
|
| 11 |
+
|
| 12 |
+
Pattern identique aux autres rendus (Sprints 41/43/62/67/72) :
|
| 13 |
+
**server-side**, pas de JavaScript, anti-injection systématique
|
| 14 |
+
via ``html.escape``.
|
| 15 |
+
|
| 16 |
+
Sortie typique
|
| 17 |
+
--------------
|
| 18 |
+
Un encart court (~80px de haut) à insérer en tête du rapport,
|
| 19 |
+
sous la synthèse factuelle :
|
| 20 |
+
|
| 21 |
+
Difficulté observée 0,62 — au 88ᵉ percentile des 47 corpus
|
| 22 |
+
précédents de votre institution. Ce corpus est plus difficile
|
| 23 |
+
que la moyenne.
|
| 24 |
+
|
| 25 |
+
[boxplot SVG horizontal avec point courant coloré]
|
| 26 |
+
|
| 27 |
+
Si moins de ``min_runs`` runs historiques ont une difficulté
|
| 28 |
+
enregistrée, ``compute_corpus_difficulty_percentile`` retourne
|
| 29 |
+
``None`` et le rendu retourne ``""`` (rapport adaptatif).
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import statistics
|
| 35 |
+
from html import escape as _e
|
| 36 |
+
from typing import Optional
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _quantiles(values: list[float]) -> tuple[float, float, float, float, float]:
|
| 40 |
+
"""Retourne (min, Q1, median, Q3, max)."""
|
| 41 |
+
if not values:
|
| 42 |
+
return (0.0, 0.0, 0.0, 0.0, 0.0)
|
| 43 |
+
sorted_v = sorted(values)
|
| 44 |
+
n = len(sorted_v)
|
| 45 |
+
if n == 1:
|
| 46 |
+
v = sorted_v[0]
|
| 47 |
+
return (v, v, v, v, v)
|
| 48 |
+
median = statistics.median(sorted_v)
|
| 49 |
+
# Calcul des quartiles avec interpolation linéaire (méthode
|
| 50 |
+
# « inclusive » : Q1 = médiane de la moitié inférieure
|
| 51 |
+
# incluant la médiane si N impair).
|
| 52 |
+
half = n // 2
|
| 53 |
+
if n % 2 == 0:
|
| 54 |
+
lower = sorted_v[:half]
|
| 55 |
+
upper = sorted_v[half:]
|
| 56 |
+
else:
|
| 57 |
+
lower = sorted_v[: half + 1]
|
| 58 |
+
upper = sorted_v[half:]
|
| 59 |
+
q1 = statistics.median(lower)
|
| 60 |
+
q3 = statistics.median(upper)
|
| 61 |
+
return (sorted_v[0], q1, median, q3, sorted_v[-1])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _build_difficulty_boxplot_svg(
|
| 65 |
+
historical_values: list[float],
|
| 66 |
+
current: float,
|
| 67 |
+
*,
|
| 68 |
+
width: int = 480,
|
| 69 |
+
height: int = 80,
|
| 70 |
+
) -> str:
|
| 71 |
+
"""Construit un boxplot horizontal SVG avec point courant.
|
| 72 |
+
|
| 73 |
+
Le SVG est autonome (pas de CSS externe) et utilise des
|
| 74 |
+
coordonnées explicites — sûr à intégrer dans n'importe quel
|
| 75 |
+
document HTML.
|
| 76 |
+
"""
|
| 77 |
+
if not historical_values:
|
| 78 |
+
return ""
|
| 79 |
+
min_v, q1, median, q3, max_v = _quantiles(historical_values)
|
| 80 |
+
# Borne du domaine : on inclut le point courant pour qu'il soit
|
| 81 |
+
# visible même s'il dépasse les valeurs historiques.
|
| 82 |
+
domain_min = min(min_v, current)
|
| 83 |
+
domain_max = max(max_v, current)
|
| 84 |
+
if domain_max == domain_min:
|
| 85 |
+
# Cas dégénéré : tous les points superposés
|
| 86 |
+
domain_min -= 0.01
|
| 87 |
+
domain_max += 0.01
|
| 88 |
+
|
| 89 |
+
margin_x = 30
|
| 90 |
+
margin_y = 10
|
| 91 |
+
plot_w = width - 2 * margin_x
|
| 92 |
+
plot_h = height - 2 * margin_y - 14 # 14px pour le label
|
| 93 |
+
cy = margin_y + plot_h // 2
|
| 94 |
+
box_top = cy - plot_h // 4
|
| 95 |
+
box_bottom = cy + plot_h // 4
|
| 96 |
+
whisker_top = cy - plot_h // 6
|
| 97 |
+
whisker_bottom = cy + plot_h // 6
|
| 98 |
+
|
| 99 |
+
def x(v: float) -> float:
|
| 100 |
+
return margin_x + (v - domain_min) / (domain_max - domain_min) * plot_w
|
| 101 |
+
|
| 102 |
+
# Le point courant : couleur selon position
|
| 103 |
+
if current < q1:
|
| 104 |
+
point_color = "#3b87d8" # bleu — plus facile que d'habitude
|
| 105 |
+
elif current > q3:
|
| 106 |
+
point_color = "#d8553b" # rouge — plus difficile
|
| 107 |
+
else:
|
| 108 |
+
point_color = "#5fa860" # vert — habituel
|
| 109 |
+
|
| 110 |
+
parts = [
|
| 111 |
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
| 112 |
+
f'width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
|
| 113 |
+
f'role="img" aria-label="Distribution de difficulté historique">',
|
| 114 |
+
# Ligne de moustache (min → max)
|
| 115 |
+
f'<line x1="{x(min_v):.1f}" y1="{cy}" x2="{x(max_v):.1f}" '
|
| 116 |
+
f'y2="{cy}" stroke="#999" stroke-width="1"/>',
|
| 117 |
+
# Moustache verticale gauche (min)
|
| 118 |
+
f'<line x1="{x(min_v):.1f}" y1="{whisker_top}" '
|
| 119 |
+
f'x2="{x(min_v):.1f}" y2="{whisker_bottom}" '
|
| 120 |
+
f'stroke="#999" stroke-width="1"/>',
|
| 121 |
+
# Moustache verticale droite (max)
|
| 122 |
+
f'<line x1="{x(max_v):.1f}" y1="{whisker_top}" '
|
| 123 |
+
f'x2="{x(max_v):.1f}" y2="{whisker_bottom}" '
|
| 124 |
+
f'stroke="#999" stroke-width="1"/>',
|
| 125 |
+
# Boîte Q1 → Q3
|
| 126 |
+
f'<rect x="{x(q1):.1f}" y="{box_top}" '
|
| 127 |
+
f'width="{x(q3) - x(q1):.1f}" height="{box_bottom - box_top}" '
|
| 128 |
+
f'fill="#e8e8e8" stroke="#666" stroke-width="1"/>',
|
| 129 |
+
# Médiane
|
| 130 |
+
f'<line x1="{x(median):.1f}" y1="{box_top}" '
|
| 131 |
+
f'x2="{x(median):.1f}" y2="{box_bottom}" '
|
| 132 |
+
f'stroke="#333" stroke-width="2"/>',
|
| 133 |
+
# Point courant (cercle plus grand que les autres marqueurs)
|
| 134 |
+
f'<circle cx="{x(current):.1f}" cy="{cy}" r="6" '
|
| 135 |
+
f'fill="{point_color}" stroke="#000" stroke-width="1"/>',
|
| 136 |
+
# Étiquettes min / max
|
| 137 |
+
f'<text x="{x(min_v):.1f}" y="{height - 2}" '
|
| 138 |
+
f'font-size="10" fill="#666" text-anchor="middle">'
|
| 139 |
+
f'{min_v:.2f}</text>',
|
| 140 |
+
f'<text x="{x(max_v):.1f}" y="{height - 2}" '
|
| 141 |
+
f'font-size="10" fill="#666" text-anchor="middle">'
|
| 142 |
+
f'{max_v:.2f}</text>',
|
| 143 |
+
# Étiquette du point courant
|
| 144 |
+
f'<text x="{x(current):.1f}" y="{margin_y + 8}" '
|
| 145 |
+
f'font-size="11" fill="{point_color}" '
|
| 146 |
+
f'text-anchor="middle" font-weight="600">'
|
| 147 |
+
f'{current:.2f}</text>',
|
| 148 |
+
"</svg>",
|
| 149 |
+
]
|
| 150 |
+
return "".join(parts)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_corpus_difficulty_baseline_html(
|
| 154 |
+
percentile_data: Optional[dict],
|
| 155 |
+
historical_values: Optional[list[float]] = None,
|
| 156 |
+
labels: Optional[dict[str, str]] = None,
|
| 157 |
+
) -> str:
|
| 158 |
+
"""Construit l'encart « Ce corpus est-il habituel ? ».
|
| 159 |
+
|
| 160 |
+
Parameters
|
| 161 |
+
----------
|
| 162 |
+
percentile_data:
|
| 163 |
+
Sortie de
|
| 164 |
+
``picarones.core.baseline_comparison.compute_corpus_difficulty_percentile``.
|
| 165 |
+
Si ``None``, retourne ``""`` (rapport adaptatif —
|
| 166 |
+
historique trop court ou difficulté absente).
|
| 167 |
+
historical_values:
|
| 168 |
+
Liste des difficultés historiques pour le boxplot. Si
|
| 169 |
+
``None`` ou vide, le boxplot est omis et seule la phrase
|
| 170 |
+
factuelle apparaît.
|
| 171 |
+
labels:
|
| 172 |
+
Map i18n.
|
| 173 |
+
|
| 174 |
+
Returns
|
| 175 |
+
-------
|
| 176 |
+
str
|
| 177 |
+
HTML de l'encart, ou ``""`` si rien à afficher.
|
| 178 |
+
"""
|
| 179 |
+
if not percentile_data:
|
| 180 |
+
return ""
|
| 181 |
+
labels = labels or {}
|
| 182 |
+
title = labels.get(
|
| 183 |
+
"baseline_corpus_title", "Ce corpus est-il habituel ?",
|
| 184 |
+
)
|
| 185 |
+
template_harder = labels.get(
|
| 186 |
+
"baseline_corpus_harder",
|
| 187 |
+
"Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ "
|
| 188 |
+
"percentile des {n_runs} corpus précédents de votre institution. "
|
| 189 |
+
"Ce corpus est plus difficile que la moyenne.",
|
| 190 |
+
)
|
| 191 |
+
template_easier = labels.get(
|
| 192 |
+
"baseline_corpus_easier",
|
| 193 |
+
"Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ "
|
| 194 |
+
"percentile des {n_runs} corpus précédents. Ce corpus est "
|
| 195 |
+
"plus facile que la moyenne.",
|
| 196 |
+
)
|
| 197 |
+
template_usual = labels.get(
|
| 198 |
+
"baseline_corpus_usual",
|
| 199 |
+
"Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ "
|
| 200 |
+
"percentile des {n_runs} corpus précédents. Ce corpus est "
|
| 201 |
+
"dans la moyenne.",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
current = float(percentile_data.get("current_difficulty", 0.0))
|
| 205 |
+
percentile = float(percentile_data.get("percentile", 0.0))
|
| 206 |
+
n_runs = int(percentile_data.get("n_runs", 0))
|
| 207 |
+
if percentile_data.get("harder_than_usual"):
|
| 208 |
+
phrase_template = template_harder
|
| 209 |
+
elif percentile_data.get("easier_than_usual"):
|
| 210 |
+
phrase_template = template_easier
|
| 211 |
+
else:
|
| 212 |
+
phrase_template = template_usual
|
| 213 |
+
phrase = phrase_template.format(
|
| 214 |
+
current=current, percentile=percentile, n_runs=n_runs,
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
svg = ""
|
| 218 |
+
if historical_values:
|
| 219 |
+
svg = _build_difficulty_boxplot_svg(
|
| 220 |
+
list(historical_values), current,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
parts = [
|
| 224 |
+
'<div class="baseline-corpus" '
|
| 225 |
+
'style="margin:1rem 0;padding:.75rem;'
|
| 226 |
+
'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
|
| 227 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 228 |
+
f'<div style="font-size:.9rem;margin-bottom:.5rem">{_e(phrase)}</div>',
|
| 229 |
+
]
|
| 230 |
+
if svg:
|
| 231 |
+
parts.append(svg)
|
| 232 |
+
parts.append("</div>")
|
| 233 |
+
return "".join(parts)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
__all__ = [
|
| 237 |
+
"build_corpus_difficulty_baseline_html",
|
| 238 |
+
]
|
|
@@ -238,5 +238,9 @@
|
|
| 238 |
"pipeline_gain_title": "Gain vs {baseline} on {label}",
|
| 239 |
"pipeline_gain_absolute_label": "Absolute gain",
|
| 240 |
"pipeline_gain_relative_label": "Relative gain",
|
| 241 |
-
"pipeline_baseline_marker": "(baseline)"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
}
|
|
|
|
| 238 |
"pipeline_gain_title": "Gain vs {baseline} on {label}",
|
| 239 |
"pipeline_gain_absolute_label": "Absolute gain",
|
| 240 |
"pipeline_gain_relative_label": "Relative gain",
|
| 241 |
+
"pipeline_baseline_marker": "(baseline)",
|
| 242 |
+
"baseline_corpus_title": "Is this corpus typical?",
|
| 243 |
+
"baseline_corpus_harder": "Difficulty observed {current:.2f} — at the {percentile:.0f}th percentile of the {n_runs} previous corpora of your institution. This corpus is harder than usual.",
|
| 244 |
+
"baseline_corpus_easier": "Difficulty observed {current:.2f} — at the {percentile:.0f}th percentile of the {n_runs} previous corpora. This corpus is easier than usual.",
|
| 245 |
+
"baseline_corpus_usual": "Difficulty observed {current:.2f} — at the {percentile:.0f}th percentile of the {n_runs} previous corpora. This corpus is in the average."
|
| 246 |
}
|
|
@@ -238,5 +238,9 @@
|
|
| 238 |
"pipeline_gain_title": "Gain vs {baseline} sur {label}",
|
| 239 |
"pipeline_gain_absolute_label": "Gain absolu",
|
| 240 |
"pipeline_gain_relative_label": "Gain relatif",
|
| 241 |
-
"pipeline_baseline_marker": "(référence)"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
}
|
|
|
|
| 238 |
"pipeline_gain_title": "Gain vs {baseline} sur {label}",
|
| 239 |
"pipeline_gain_absolute_label": "Gain absolu",
|
| 240 |
"pipeline_gain_relative_label": "Gain relatif",
|
| 241 |
+
"pipeline_baseline_marker": "(référence)",
|
| 242 |
+
"baseline_corpus_title": "Ce corpus est-il habituel ?",
|
| 243 |
+
"baseline_corpus_harder": "Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ percentile des {n_runs} corpus précédents de votre institution. Ce corpus est plus difficile que la moyenne.",
|
| 244 |
+
"baseline_corpus_easier": "Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ percentile des {n_runs} corpus précédents. Ce corpus est plus facile que la moyenne.",
|
| 245 |
+
"baseline_corpus_usual": "Difficulté observée {current:.2f} — au {percentile:.0f}ᵉ percentile des {n_runs} corpus précédents. Ce corpus est dans la moyenne."
|
| 246 |
}
|
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 74 — A.I.3 chantier 1 : encart « Ce corpus est-il habituel ? ».
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``build_corpus_difficulty_baseline_html`` :
|
| 6 |
+
- Phrase factuelle rendue (harder / easier / usual)
|
| 7 |
+
- Chaîne vide si ``percentile_data is None``
|
| 8 |
+
- SVG omis si ``historical_values`` vide / None
|
| 9 |
+
- SVG rendu si valeurs fournies
|
| 10 |
+
2. SVG :
|
| 11 |
+
- Bien formé (``<svg ...>...</svg>``)
|
| 12 |
+
- Point courant placé au bon endroit (couleur selon position)
|
| 13 |
+
- Boîte Q1-Q3, médiane, moustaches min-max
|
| 14 |
+
3. Anti-injection : labels i18n contenant ``<script>`` échappés.
|
| 15 |
+
4. Complétude i18n : nouvelles clés ``baseline_corpus_*`` présentes
|
| 16 |
+
en FR et EN.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from picarones.report.baseline_render import (
|
| 25 |
+
_build_difficulty_boxplot_svg,
|
| 26 |
+
_quantiles,
|
| 27 |
+
build_corpus_difficulty_baseline_html,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 32 |
+
# 1. _quantiles
|
| 33 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TestQuantiles:
|
| 37 |
+
def test_simple(self) -> None:
|
| 38 |
+
v = [1.0, 2.0, 3.0, 4.0, 5.0]
|
| 39 |
+
mn, q1, med, q3, mx = _quantiles(v)
|
| 40 |
+
assert mn == 1.0
|
| 41 |
+
assert mx == 5.0
|
| 42 |
+
assert med == 3.0
|
| 43 |
+
|
| 44 |
+
def test_empty(self) -> None:
|
| 45 |
+
assert _quantiles([]) == (0.0, 0.0, 0.0, 0.0, 0.0)
|
| 46 |
+
|
| 47 |
+
def test_single(self) -> None:
|
| 48 |
+
assert _quantiles([0.5]) == (0.5, 0.5, 0.5, 0.5, 0.5)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 52 |
+
# 2. SVG boxplot
|
| 53 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class TestSvg:
|
| 57 |
+
def test_well_formed(self) -> None:
|
| 58 |
+
svg = _build_difficulty_boxplot_svg(
|
| 59 |
+
[0.1, 0.2, 0.3, 0.4, 0.5], current=0.35,
|
| 60 |
+
)
|
| 61 |
+
assert svg.startswith('<svg')
|
| 62 |
+
assert svg.endswith('</svg>')
|
| 63 |
+
assert 'xmlns="http://www.w3.org/2000/svg"' in svg
|
| 64 |
+
|
| 65 |
+
def test_empty_returns_empty(self) -> None:
|
| 66 |
+
assert _build_difficulty_boxplot_svg([], current=0.5) == ""
|
| 67 |
+
|
| 68 |
+
def test_point_color_harder(self) -> None:
|
| 69 |
+
# current > Q3 → rouge
|
| 70 |
+
svg = _build_difficulty_boxplot_svg(
|
| 71 |
+
[0.1, 0.2, 0.3, 0.4, 0.5], current=0.95,
|
| 72 |
+
)
|
| 73 |
+
assert "#d8553b" in svg
|
| 74 |
+
|
| 75 |
+
def test_point_color_easier(self) -> None:
|
| 76 |
+
# current < Q1 → bleu
|
| 77 |
+
svg = _build_difficulty_boxplot_svg(
|
| 78 |
+
[0.3, 0.4, 0.5, 0.6, 0.7], current=0.1,
|
| 79 |
+
)
|
| 80 |
+
assert "#3b87d8" in svg
|
| 81 |
+
|
| 82 |
+
def test_point_color_usual(self) -> None:
|
| 83 |
+
# current entre Q1 et Q3 → vert
|
| 84 |
+
svg = _build_difficulty_boxplot_svg(
|
| 85 |
+
[0.1, 0.2, 0.3, 0.4, 0.5], current=0.3,
|
| 86 |
+
)
|
| 87 |
+
assert "#5fa860" in svg
|
| 88 |
+
|
| 89 |
+
def test_contains_box_and_whiskers(self) -> None:
|
| 90 |
+
svg = _build_difficulty_boxplot_svg(
|
| 91 |
+
[0.1, 0.2, 0.3, 0.4, 0.5], current=0.3,
|
| 92 |
+
)
|
| 93 |
+
# Au moins un rect (boîte) et plusieurs lignes (moustaches)
|
| 94 |
+
assert "<rect" in svg
|
| 95 |
+
assert "<line" in svg
|
| 96 |
+
# Cercle pour le point courant
|
| 97 |
+
assert "<circle" in svg
|
| 98 |
+
|
| 99 |
+
def test_degenerate_all_same(self) -> None:
|
| 100 |
+
# Toutes les valeurs identiques : ne doit pas crasher
|
| 101 |
+
svg = _build_difficulty_boxplot_svg(
|
| 102 |
+
[0.5, 0.5, 0.5], current=0.5,
|
| 103 |
+
)
|
| 104 |
+
assert svg.startswith('<svg')
|
| 105 |
+
|
| 106 |
+
def test_current_outside_historical_range(self) -> None:
|
| 107 |
+
# Le point courant peut dépasser les valeurs historiques
|
| 108 |
+
svg = _build_difficulty_boxplot_svg(
|
| 109 |
+
[0.1, 0.2, 0.3], current=0.99,
|
| 110 |
+
)
|
| 111 |
+
assert svg.startswith('<svg')
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 115 |
+
# 3. build_corpus_difficulty_baseline_html
|
| 116 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class TestBuildHtml:
|
| 120 |
+
def test_returns_empty_when_no_data(self) -> None:
|
| 121 |
+
assert build_corpus_difficulty_baseline_html(None) == ""
|
| 122 |
+
|
| 123 |
+
def test_renders_phrase_harder(self) -> None:
|
| 124 |
+
data = {
|
| 125 |
+
"current_difficulty": 0.62,
|
| 126 |
+
"percentile": 88.0,
|
| 127 |
+
"n_runs": 47,
|
| 128 |
+
"median_historical": 0.40,
|
| 129 |
+
"harder_than_usual": True,
|
| 130 |
+
"easier_than_usual": False,
|
| 131 |
+
}
|
| 132 |
+
html = build_corpus_difficulty_baseline_html(data)
|
| 133 |
+
assert "0.62" in html
|
| 134 |
+
assert "88" in html
|
| 135 |
+
assert "47" in html
|
| 136 |
+
assert "plus difficile" in html
|
| 137 |
+
|
| 138 |
+
def test_renders_phrase_easier(self) -> None:
|
| 139 |
+
data = {
|
| 140 |
+
"current_difficulty": 0.10,
|
| 141 |
+
"percentile": 12.0,
|
| 142 |
+
"n_runs": 30,
|
| 143 |
+
"median_historical": 0.40,
|
| 144 |
+
"harder_than_usual": False,
|
| 145 |
+
"easier_than_usual": True,
|
| 146 |
+
}
|
| 147 |
+
html = build_corpus_difficulty_baseline_html(data)
|
| 148 |
+
assert "plus facile" in html
|
| 149 |
+
|
| 150 |
+
def test_renders_phrase_usual(self) -> None:
|
| 151 |
+
data = {
|
| 152 |
+
"current_difficulty": 0.40,
|
| 153 |
+
"percentile": 50.0,
|
| 154 |
+
"n_runs": 20,
|
| 155 |
+
"median_historical": 0.40,
|
| 156 |
+
"harder_than_usual": False,
|
| 157 |
+
"easier_than_usual": False,
|
| 158 |
+
}
|
| 159 |
+
html = build_corpus_difficulty_baseline_html(data)
|
| 160 |
+
assert "dans la moyenne" in html
|
| 161 |
+
|
| 162 |
+
def test_svg_omitted_when_no_history_values(self) -> None:
|
| 163 |
+
data = {
|
| 164 |
+
"current_difficulty": 0.40,
|
| 165 |
+
"percentile": 50.0,
|
| 166 |
+
"n_runs": 20,
|
| 167 |
+
"median_historical": 0.40,
|
| 168 |
+
"harder_than_usual": False,
|
| 169 |
+
"easier_than_usual": False,
|
| 170 |
+
}
|
| 171 |
+
html = build_corpus_difficulty_baseline_html(data)
|
| 172 |
+
assert "<svg" not in html
|
| 173 |
+
|
| 174 |
+
def test_svg_present_when_history_provided(self) -> None:
|
| 175 |
+
data = {
|
| 176 |
+
"current_difficulty": 0.62,
|
| 177 |
+
"percentile": 88.0,
|
| 178 |
+
"n_runs": 5,
|
| 179 |
+
"median_historical": 0.30,
|
| 180 |
+
"harder_than_usual": True,
|
| 181 |
+
"easier_than_usual": False,
|
| 182 |
+
}
|
| 183 |
+
html = build_corpus_difficulty_baseline_html(
|
| 184 |
+
data, historical_values=[0.1, 0.2, 0.3, 0.4, 0.5],
|
| 185 |
+
)
|
| 186 |
+
assert "<svg" in html
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 190 |
+
# 4. Anti-injection
|
| 191 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TestAntiInjection:
|
| 195 |
+
def test_label_via_i18n_escaped(self) -> None:
|
| 196 |
+
data = {
|
| 197 |
+
"current_difficulty": 0.40, "percentile": 50.0,
|
| 198 |
+
"n_runs": 20, "median_historical": 0.40,
|
| 199 |
+
"harder_than_usual": False, "easier_than_usual": False,
|
| 200 |
+
}
|
| 201 |
+
labels = {"baseline_corpus_title": "<b>Hack</b>"}
|
| 202 |
+
html = build_corpus_difficulty_baseline_html(data, labels=labels)
|
| 203 |
+
assert "<b>Hack</b>" not in html
|
| 204 |
+
assert "<b>Hack</b>" in html
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 208 |
+
# 5. Complétude i18n
|
| 209 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class TestI18nCompleteness:
|
| 213 |
+
def _load(self, lang: str) -> dict:
|
| 214 |
+
path = (
|
| 215 |
+
Path(__file__).parent.parent
|
| 216 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 217 |
+
)
|
| 218 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 219 |
+
|
| 220 |
+
def test_all_keys_present_fr(self) -> None:
|
| 221 |
+
d = self._load("fr")
|
| 222 |
+
for key in (
|
| 223 |
+
"baseline_corpus_title",
|
| 224 |
+
"baseline_corpus_harder",
|
| 225 |
+
"baseline_corpus_easier",
|
| 226 |
+
"baseline_corpus_usual",
|
| 227 |
+
):
|
| 228 |
+
assert key in d, f"manque clé FR : {key}"
|
| 229 |
+
|
| 230 |
+
def test_all_keys_present_en(self) -> None:
|
| 231 |
+
d_fr = self._load("fr")
|
| 232 |
+
d_en = self._load("en")
|
| 233 |
+
for key in d_fr:
|
| 234 |
+
if key.startswith("baseline_corpus_"):
|
| 235 |
+
assert key in d_en, f"manque clé EN : {key}"
|