Spaces:
Running
sprint95: B.4 - visualisation DAG d'un pipeline composé (SVG server-side)
Browse filesOutil d'inspection, pas de construction - le YAML reste source de
vérité. Permet d'auditer rapidement la qualité d'une pipeline d'axe
B avec plusieurs jonctions.
picarones/report/pipeline_dag_render.py :
- build_pipeline_dag_html(nodes, labels, edges=None,
thresholds=(0.05, 0.15), higher_is_better=False) :
graphe orienté gauche → droite en SVG natif (pas de lib, pas de JS).
- Nœuds = rectangles annotés du nom + input/output types.
- Arêtes = flèches colorées vert/orange/rouge selon valeur de la
métrique à la jonction, étiquette type + métrique:valeur formatée.
- Légende intégrée avec seuils.
- Mode higher_is_better=True inverse la sémantique (F1/recall).
- Adaptive : "" si moins d'un nœud.
- Auto-déduction d'arêtes séquentielles si non fournies.
- Anti-injection sur 4 vecteurs (nom nœud, artifact_type,
metric_name, input/output_types).
Pas de drag-and-drop, pas de drill-down - le visuel sert à inspecter
et déboguer. Le drill-down par document reste dans error_absorption
(Sprint 94).
6 clés i18n FR/EN (dag_*). 18 tests dans test_sprint95_pipeline_dag.py
incluant 3 cas de couleur sur seuil, higher_is_better, anti-injection
sur 4 vecteurs, rendu EN, complétude i18n.
Tests : 3055 passed, 2 skipped.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +45 -0
- CLAUDE.md +2 -1
- picarones/report/i18n/en.json +7 -1
- picarones/report/i18n/fr.json +7 -1
- picarones/report/pipeline_dag_render.py +307 -0
- tests/test_sprint95_pipeline_dag.py +208 -0
|
@@ -16,6 +16,51 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 94 — B.3 : métrique d'absorption d'erreur (couche
|
| 20 |
calcul + vue HTML).** Quand un module post-correction LLM
|
| 21 |
aplatit les différences entre OCR amont, ce n'est pas qu'il
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 95 — B.4 : visualisation DAG d'un pipeline composé
|
| 20 |
+
(rendu SVG server-side).** Outil d'**inspection**, pas de
|
| 21 |
+
construction — le YAML reste source de vérité. Permet
|
| 22 |
+
d'auditer rapidement la qualité d'une pipeline d'axe B
|
| 23 |
+
(Sprint 63+). Nouveau module
|
| 24 |
+
`picarones/report/pipeline_dag_render.py` :
|
| 25 |
+
`build_pipeline_dag_html(nodes, labels, edges=None,
|
| 26 |
+
thresholds=(0.05, 0.15), higher_is_better=False)` rend un
|
| 27 |
+
graphe orienté gauche → droite en SVG natif (pas de
|
| 28 |
+
bibliothèque, pas de JS). Chaque nœud est un rectangle
|
| 29 |
+
annoté du nom du module + types d'entrée/sortie. Chaque
|
| 30 |
+
arête est une flèche colorée vert/orange/rouge selon la
|
| 31 |
+
valeur de la métrique calculée à la jonction, avec
|
| 32 |
+
étiquette ``type d'artefact`` + ``métrique : valeur``
|
| 33 |
+
(formatée en pourcent ou décimal). Légende intégrée avec
|
| 34 |
+
les seuils. Mode ``higher_is_better=True`` inverse la
|
| 35 |
+
sémantique pour les métriques type F1/recall. Adaptive :
|
| 36 |
+
``""`` si moins d'un nœud. Auto-déduction des arêtes
|
| 37 |
+
séquentielles si non fournies. Anti-injection systématique
|
| 38 |
+
via ``html.escape`` sur le nom du nœud, le type d'artefact,
|
| 39 |
+
le nom de métrique et les listes input/output_types.
|
| 40 |
+
|
| 41 |
+
**Pas de drag-and-drop, pas de notebook, pas de drill-down
|
| 42 |
+
par document** : le visuel sert à inspecter et déboguer,
|
| 43 |
+
pas à construire. Une institution sérieuse versionne ses
|
| 44 |
+
pipelines en YAML dans Git, pas en JSON exporté d'une UI.
|
| 45 |
+
Le drill-down par document reste sur le tableau de
|
| 46 |
+
``error_absorption`` (Sprint 94) qui montre déjà les tokens
|
| 47 |
+
corrigés / introduits par jonction.
|
| 48 |
+
|
| 49 |
+
+6 clés i18n FR/EN (`dag_*`). +18 tests dans
|
| 50 |
+
`test_sprint95_pipeline_dag.py` (vide → "", single node sans
|
| 51 |
+
flèche, 2 nœuds 1 arête avec étiquettes + valeur formatée
|
| 52 |
+
4.0%, chaîne 3 nœuds 2 flèches, auto-déduction d'arêtes,
|
| 53 |
+
3 cas de couleur (vert ≤ 0.05, jaune ≤ 0.15, rouge > 0.15),
|
| 54 |
+
inversion higher_is_better avec F1=0.96 → vert, nœud
|
| 55 |
+
inconnu dans une arête skipped, valeur de métrique absente
|
| 56 |
+
affichée comme — ; anti-injection 4 vecteurs : nom de nœud,
|
| 57 |
+
artifact_type, metric_name, input/output types ; rendu en
|
| 58 |
+
anglais ; complétude i18n 6 clés). **Verrou levé** : un
|
| 59 |
+
benchmark d'axe B avec 3+ étapes (par ex. OCR → LLM →
|
| 60 |
+
ALTO_mapper) voit immédiatement à quelle jonction la
|
| 61 |
+
qualité décroche, sans avoir à parcourir un tableau de
|
| 62 |
+
métriques.
|
| 63 |
+
|
| 64 |
- **Sprint 94 — B.3 : métrique d'absorption d'erreur (couche
|
| 65 |
calcul + vue HTML).** Quand un module post-correction LLM
|
| 66 |
aplatit les différences entre OCR amont, ce n'est pas qu'il
|
|
@@ -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 |
| 94 | **Sprint 63 du plan d'évolution 2026 — B.3 : métrique d'absorption d'erreur (couche calcul + vue HTML)**. Quand un module post-correction LLM aplatit les différences entre OCR amont, ce n'est pas qu'il « améliore » tous les moteurs — c'est qu'il introduit ses propres biais qui dominent ceux de l'OCR. À chaque jonction, deux flux séparés : taux de correction (parmi les erreurs avant, combien corrigées) et taux d'introduction (parmi les erreurs après, combien nouvelles). Nouveau module `picarones/core/error_absorption.py` : `compute_error_absorption(reference, before, after, case_sensitive=False)` alignement multi-set token-level sur whitespace, retourne `{n_gt_tokens, n_errors_before, n_errors_after, n_corrected, n_introduced, n_kept_wrong, correction_rate (None si 0 err avant), introduction_rate (None si 0 err après), net_improvement, corrected_tokens, introduced_tokens (casse GT)}`. None si GT vide. `aggregate_error_absorption(per_doc, sample_tokens=50)` somme corpus-wide + recalcul micro + cap échantillon. Généralisation du score sur-normalisation (A.I.7) à toute jonction OCR→LLM/OCR→reconstructor/VLM→ALTO_mapper. Pas de classification d'erreur (volume, pas qualité — taxonomy reste dans Sprint 5). Module de rendu `picarones/report/error_absorption_render.py` : tableau résumé jonctions × {erreurs avant, après, corrigées coloré vert, introduites coloré rouge, % corrigées (rouge → vert), % introduites (vert → rouge), amélioration nette colorée selon signe + magnitude, échantillon tokens introduits cap}. Adaptive masking. Module pur — l'utilisateur compose les `junctions` depuis `PipelineBenchmarkResult` (Sprint 64). Visualisation Sankey reportée. +11 clés i18n FR/EN (`absorption_*`). +20 tests dans `test_sprint94_error_absorption.py` (identité, perfect correction, pure introduction, **cas réaliste mix maistre Pierre du Bois → maître Pierre du Bois** corrige+introduit en parallèle, GT vide → None, case-insensitive + opt-in, multiplicité, agrégation micro-rate + skip None + cap, vue HTML 4 cas dont anti-injection junction_name + échantillon introduits + FR + EN, complétude i18n 11 clés). **Verrou levé** : un bench de pipeline composée distingue désormais un module qui *corrige* d'un module qui *absorbe* — *« le LLM corrige 65 % des erreurs OCR mais introduit 12 % de nouvelles erreurs (modernisations maistre/nostre) »*. Sans cette métrique, on confondait correction et écrasement. |
|
| 211 |
| 93 | **Sprint 62 du plan d'évolution 2026 — A.II.7 : métriques d'image prédictives (couche calcul + vue HTML)**. `image_quality.py` (Sprint 5) mesurait des features indépendamment ; ce module les combine en deux indicateurs corpus-level. Nouveau module `picarones/core/image_predictive.py` : `compute_paleographic_complexity(quality, weights)` retourne score ∈ [0,1] + components + weights_used (combinaison pondérée éditoriale 0.30 noise / 0.30 blur / 0.20 low_contrast / 0.20 rotation, bornes forcées) ; `compute_corpus_homogeneity(image_qualities)` retourne score ∈ [0,1] (moyenne des écart-types normalisés sur 4 features) + n_docs + per_feature, 0 = uniforme (moyenne globale fiable), 1 = très hétérogène ; `aggregate_corpus_predictive` synthétise complexité (mean/median/min/max/stdev) + homogeneity. Pas de prédiction CER absolue (philosophie banc d'essai exclut un modèle entraîné par moteur). Module de rendu `picarones/report/image_predictive_render.py` : 2 blocs — tableau résumé complexité (mean coloré gradient vert → rouge, médiane, min, max, stdev, docs) + tableau homogénéité (score coloré + détail par feature mean/stdev/contribution normalisée). Adaptive masking. Module pur — l'utilisateur compose. +20 clés i18n FR/EN (`imgpred_*`). +21 tests dans `test_sprint93_image_predictive.py` (cas trivial → ≈0, cas extrême → ≈1, bornes [0,1], poids custom, défauts somment à 1, garde-fous None ; corpus uniforme → 0, hétérogène > 0.5, lt 2 → None ; cas réaliste BnF mix trivial/difficile ; vue HTML 4 cas dont anti-injection FR + EN ; complétude i18n 19 clés). **Verrou levé** : un benchmark BnF voit désormais *« corpus-wide complexity 0,42 (modérée), homogeneity 0,18 (uniforme — moyenne fiable) »* dans la vue Analyses — explique une partie du CER observé sans prédiction prescriptive. |
|
| 212 |
| 92 | **Sprint 61 du plan d'évolution 2026 — A.II.9 : métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML)**. L'historique SQLite (Sprint 8) collectait sans qu'aucune métrique n'en sorte. Complémentaire à A.I.3 qui dit *« écart anormal sur ce corpus »* sans caractériser la dynamique. Nouveau module `picarones/core/longitudinal.py` : `compute_linear_trend` régression OLS pure Python sans scipy retourne `LinearTrend(slope, intercept, r_squared, n_runs)` ; `detect_change_point(series, min_segment_size=3)` balayage exhaustif (Pettitt simplifié) retourne `ChangePointResult(index, timestamp, mean_before, mean_after, delta, n_before, n_after)` ; `compute_engine_longitudinal` combine les deux avec garde-fou `min_runs_for_trend=3` et seuil `change_point_threshold=0.01` (1 pt CER) ; `compute_corpus_longitudinal` agrège tous les moteurs. Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170, MEDIUM par défaut, HIGH si `|absolute_delta| ≥ 0.05`) + détecteur lit `benchmark_data["longitudinal_trends"]`, déclenche si pente > +1 pt CER/an **ou** change-point delta > 1 pt CER, payload trace `pattern in {"trend", "change_point", "trend_and_change_point"}`. Templates FR/EN sans chiffres en dur. Ajout aux paires complémentaires : `(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` et `(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)`. Module de rendu `picarones/report/longitudinal_render.py` : tableau moteur × {n_runs, premier CER, dernier CER, Δ cumulé coloré (vert→orange→rouge sur ±5 pts ; bleu si amélioration), pente annualisée, R², point de rupture avec timestamp + delta}. Tri par Δ décroissant. Adaptive masking. +10 clés i18n FR/EN (`longitudinal_*`). +28 tests (régression OLS, change-point, intégration entries + filtre corpus + min_runs + threshold, multi-moteurs, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 10 clés). **Verrou levé** : un benchmark voit désormais *« sur les 8 runs historiques pour tess, le CER moyen est passé de 4 % à 7 % (variation cumulée 3 points) »* — permet de relier une régression à un changement de pipeline. |
|
|
@@ -312,7 +313,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 312 |
## Contexte développement
|
| 313 |
|
| 314 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 315 |
-
- **Tests** :
|
| 316 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 317 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 318 |
- **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 |
+
| 95 | **Sprint 64 du plan d'évolution 2026 — B.4 : visualisation DAG d'un pipeline composé (rendu SVG server-side)**. Outil d'**inspection**, pas de construction — le YAML reste source de vérité. Nouveau module `picarones/report/pipeline_dag_render.py` : `build_pipeline_dag_html(nodes, labels, edges=None, thresholds=(0.05, 0.15), higher_is_better=False)` rend un graphe orienté gauche → droite en SVG natif (pas de bibliothèque, pas de JS). Nœuds = rectangles avec nom + input/output types. Arêtes = flèches colorées vert/orange/rouge selon la valeur de métrique à la jonction, avec étiquette `type + métrique : valeur` (formatée %). Légende intégrée. Mode `higher_is_better=True` inverse la sémantique pour F1/recall. Adaptive : `""` si moins d'un nœud. Auto-déduction d'arêtes séquentielles si non fournies. Anti-injection systématique sur 4 vecteurs (nom nœud, artifact_type, metric_name, input/output_types). Pas de drag-and-drop, pas de drill-down par document — le visuel sert à inspecter et déboguer, pas à construire. Le drill-down reste dans `error_absorption` (Sprint 94). +6 clés i18n FR/EN (`dag_*`). +18 tests dans `test_sprint95_pipeline_dag.py` (vide, single node, 2 nœuds 1 arête, chaîne 3 nœuds, auto-edges, 3 couleurs sur seuil, higher_is_better, ghost node skipped, valeur absente, anti-injection 4 vecteurs, rendu EN, complétude i18n 6 clés). **Verrou levé** : un benchmark d'axe B voit immédiatement à quelle jonction la qualité décroche, sans parcourir un tableau de métriques. |
|
| 211 |
| 94 | **Sprint 63 du plan d'évolution 2026 — B.3 : métrique d'absorption d'erreur (couche calcul + vue HTML)**. Quand un module post-correction LLM aplatit les différences entre OCR amont, ce n'est pas qu'il « améliore » tous les moteurs — c'est qu'il introduit ses propres biais qui dominent ceux de l'OCR. À chaque jonction, deux flux séparés : taux de correction (parmi les erreurs avant, combien corrigées) et taux d'introduction (parmi les erreurs après, combien nouvelles). Nouveau module `picarones/core/error_absorption.py` : `compute_error_absorption(reference, before, after, case_sensitive=False)` alignement multi-set token-level sur whitespace, retourne `{n_gt_tokens, n_errors_before, n_errors_after, n_corrected, n_introduced, n_kept_wrong, correction_rate (None si 0 err avant), introduction_rate (None si 0 err après), net_improvement, corrected_tokens, introduced_tokens (casse GT)}`. None si GT vide. `aggregate_error_absorption(per_doc, sample_tokens=50)` somme corpus-wide + recalcul micro + cap échantillon. Généralisation du score sur-normalisation (A.I.7) à toute jonction OCR→LLM/OCR→reconstructor/VLM→ALTO_mapper. Pas de classification d'erreur (volume, pas qualité — taxonomy reste dans Sprint 5). Module de rendu `picarones/report/error_absorption_render.py` : tableau résumé jonctions × {erreurs avant, après, corrigées coloré vert, introduites coloré rouge, % corrigées (rouge → vert), % introduites (vert → rouge), amélioration nette colorée selon signe + magnitude, échantillon tokens introduits cap}. Adaptive masking. Module pur — l'utilisateur compose les `junctions` depuis `PipelineBenchmarkResult` (Sprint 64). Visualisation Sankey reportée. +11 clés i18n FR/EN (`absorption_*`). +20 tests dans `test_sprint94_error_absorption.py` (identité, perfect correction, pure introduction, **cas réaliste mix maistre Pierre du Bois → maître Pierre du Bois** corrige+introduit en parallèle, GT vide → None, case-insensitive + opt-in, multiplicité, agrégation micro-rate + skip None + cap, vue HTML 4 cas dont anti-injection junction_name + échantillon introduits + FR + EN, complétude i18n 11 clés). **Verrou levé** : un bench de pipeline composée distingue désormais un module qui *corrige* d'un module qui *absorbe* — *« le LLM corrige 65 % des erreurs OCR mais introduit 12 % de nouvelles erreurs (modernisations maistre/nostre) »*. Sans cette métrique, on confondait correction et écrasement. |
|
| 212 |
| 93 | **Sprint 62 du plan d'évolution 2026 — A.II.7 : métriques d'image prédictives (couche calcul + vue HTML)**. `image_quality.py` (Sprint 5) mesurait des features indépendamment ; ce module les combine en deux indicateurs corpus-level. Nouveau module `picarones/core/image_predictive.py` : `compute_paleographic_complexity(quality, weights)` retourne score ∈ [0,1] + components + weights_used (combinaison pondérée éditoriale 0.30 noise / 0.30 blur / 0.20 low_contrast / 0.20 rotation, bornes forcées) ; `compute_corpus_homogeneity(image_qualities)` retourne score ∈ [0,1] (moyenne des écart-types normalisés sur 4 features) + n_docs + per_feature, 0 = uniforme (moyenne globale fiable), 1 = très hétérogène ; `aggregate_corpus_predictive` synthétise complexité (mean/median/min/max/stdev) + homogeneity. Pas de prédiction CER absolue (philosophie banc d'essai exclut un modèle entraîné par moteur). Module de rendu `picarones/report/image_predictive_render.py` : 2 blocs — tableau résumé complexité (mean coloré gradient vert → rouge, médiane, min, max, stdev, docs) + tableau homogénéité (score coloré + détail par feature mean/stdev/contribution normalisée). Adaptive masking. Module pur — l'utilisateur compose. +20 clés i18n FR/EN (`imgpred_*`). +21 tests dans `test_sprint93_image_predictive.py` (cas trivial → ≈0, cas extrême → ≈1, bornes [0,1], poids custom, défauts somment à 1, garde-fous None ; corpus uniforme → 0, hétérogène > 0.5, lt 2 → None ; cas réaliste BnF mix trivial/difficile ; vue HTML 4 cas dont anti-injection FR + EN ; complétude i18n 19 clés). **Verrou levé** : un benchmark BnF voit désormais *« corpus-wide complexity 0,42 (modérée), homogeneity 0,18 (uniforme — moyenne fiable) »* dans la vue Analyses — explique une partie du CER observé sans prédiction prescriptive. |
|
| 213 |
| 92 | **Sprint 61 du plan d'évolution 2026 — A.II.9 : métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML)**. L'historique SQLite (Sprint 8) collectait sans qu'aucune métrique n'en sorte. Complémentaire à A.I.3 qui dit *« écart anormal sur ce corpus »* sans caractériser la dynamique. Nouveau module `picarones/core/longitudinal.py` : `compute_linear_trend` régression OLS pure Python sans scipy retourne `LinearTrend(slope, intercept, r_squared, n_runs)` ; `detect_change_point(series, min_segment_size=3)` balayage exhaustif (Pettitt simplifié) retourne `ChangePointResult(index, timestamp, mean_before, mean_after, delta, n_before, n_after)` ; `compute_engine_longitudinal` combine les deux avec garde-fou `min_runs_for_trend=3` et seuil `change_point_threshold=0.01` (1 pt CER) ; `compute_corpus_longitudinal` agrège tous les moteurs. Nouveau `FactType.REGRESSION_IN_HISTORY` (priority 170, MEDIUM par défaut, HIGH si `|absolute_delta| ≥ 0.05`) + détecteur lit `benchmark_data["longitudinal_trends"]`, déclenche si pente > +1 pt CER/an **ou** change-point delta > 1 pt CER, payload trace `pattern in {"trend", "change_point", "trend_and_change_point"}`. Templates FR/EN sans chiffres en dur. Ajout aux paires complémentaires : `(GLOBAL_LEADER_CER, REGRESSION_IN_HISTORY)` et `(ENGINE_OFF_BASELINE, REGRESSION_IN_HISTORY)`. Module de rendu `picarones/report/longitudinal_render.py` : tableau moteur × {n_runs, premier CER, dernier CER, Δ cumulé coloré (vert→orange→rouge sur ±5 pts ; bleu si amélioration), pente annualisée, R², point de rupture avec timestamp + delta}. Tri par Δ décroissant. Adaptive masking. +10 clés i18n FR/EN (`longitudinal_*`). +28 tests (régression OLS, change-point, intégration entries + filtre corpus + min_runs + threshold, multi-moteurs, détecteur 6 cas, **traçabilité anti-hallucination FR + EN** sur sentences de `build_synthesis`, vue HTML 4 cas dont anti-injection, complétude i18n 10 clés). **Verrou levé** : un benchmark voit désormais *« sur les 8 runs historiques pour tess, le CER moyen est passé de 4 % à 7 % (variation cumulée 3 points) »* — permet de relier une régression à un changement de pipeline. |
|
|
|
|
| 313 |
## Contexte développement
|
| 314 |
|
| 315 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 316 |
+
- **Tests** : 3055 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprints 47-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; Sprints 75-77 = A.I.4 livré bout-en-bout ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 couche calcul ; Sprint 82 = A.I.9 — « Leviers d'amélioration » bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy ; Sprint 85 = A.II.5b — précision séquences numériques ; Sprint 86 = A.II.5 bout-en-bout (câblage runner + vues HTML) ; Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout ; Sprint 88 = A.I.8 — vue HTML « Déficit projeté de robustesse » bout-en-bout ; Sprint 89 = A.II.8b — score de spécialisation inter-moteurs (couche calcul + vue HTML « Top paires spécialisées ») ; Sprint 90 = A.II.4 finition — détecteur narratif `engine_unstable` + vue HTML stabilité multi-runs ; Sprint 91 = A.II.6 — métriques économiques (throughput effectif + coût marginal par erreur évitée, couche calcul + vue HTML throughput) ; Sprint 92 = A.II.9 — métriques longitudinales (régression linéaire + change-point + détecteur narratif + vue HTML) ; Sprint 93 = A.II.7 — métriques d'image prédictives (complexité paléographique + homogénéité corpus, couche calcul + vue HTML) ; Sprint 94 = B.3 — métrique d'absorption d'erreur (corrections vs introductions par jonction de pipeline, couche calcul + vue HTML) ; **Sprint 95 = B.4 — visualisation DAG d'un pipeline composé (rendu SVG server-side, outil d'inspection)**)
|
| 317 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 318 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 319 |
- **Transcript de la conversation de développement** :
|
|
@@ -381,5 +381,11 @@
|
|
| 381 |
"absorption_corr_rate": "% corrected",
|
| 382 |
"absorption_intro_rate": "% introduced",
|
| 383 |
"absorption_net": "Net improvement",
|
| 384 |
-
"absorption_sample": "Sample (intro)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
}
|
|
|
|
| 381 |
"absorption_corr_rate": "% corrected",
|
| 382 |
"absorption_intro_rate": "% introduced",
|
| 383 |
"absorption_net": "Net improvement",
|
| 384 |
+
"absorption_sample": "Sample (intro)",
|
| 385 |
+
"dag_title": "Pipeline DAG",
|
| 386 |
+
"dag_note": "Directed graph of the composed pipeline. Each edge shows the artifact type transmitted and the metric computed at the junction. Green/orange/red colour code by threshold. Inspection tool — YAML remains the source of truth.",
|
| 387 |
+
"dag_legend": "Reading",
|
| 388 |
+
"dag_legend_green": "high quality",
|
| 389 |
+
"dag_legend_yellow": "moderate quality",
|
| 390 |
+
"dag_legend_red": "low quality"
|
| 391 |
}
|
|
@@ -381,5 +381,11 @@
|
|
| 381 |
"absorption_corr_rate": "% corrigées",
|
| 382 |
"absorption_intro_rate": "% introduites",
|
| 383 |
"absorption_net": "Amélioration nette",
|
| 384 |
-
"absorption_sample": "Échantillon (intro)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
}
|
|
|
|
| 381 |
"absorption_corr_rate": "% corrigées",
|
| 382 |
"absorption_intro_rate": "% introduites",
|
| 383 |
"absorption_net": "Amélioration nette",
|
| 384 |
+
"absorption_sample": "Échantillon (intro)",
|
| 385 |
+
"dag_title": "Pipeline DAG",
|
| 386 |
+
"dag_note": "Graphe orienté du pipeline composé. Chaque arête porte le type d'artefact transmis et la métrique calculée à la jonction. Code couleur vert/orange/rouge selon le seuil. Outil d'inspection — le YAML reste source de vérité.",
|
| 387 |
+
"dag_legend": "Lecture",
|
| 388 |
+
"dag_legend_green": "qualité élevée",
|
| 389 |
+
"dag_legend_yellow": "qualité moyenne",
|
| 390 |
+
"dag_legend_red": "qualité faible"
|
| 391 |
}
|
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visualisation DAG d'un pipeline composé — Sprint 95 (B.4).
|
| 2 |
+
|
| 3 |
+
Sprint 95 — B.4 du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Outil d'inspection, pas de construction
|
| 6 |
+
---------------------------------------
|
| 7 |
+
Le YAML reste source de vérité. Cette vue **affiche** le
|
| 8 |
+
graphe orienté de la pipeline pour permettre l'inspection et
|
| 9 |
+
le debug d'un benchmark d'axe B (Sprint 63+) — elle ne
|
| 10 |
+
construit rien, ne supporte pas le drag-and-drop, n'exporte
|
| 11 |
+
aucun JSON modifiable.
|
| 12 |
+
|
| 13 |
+
Pattern identique aux autres rendus : SVG **server-side**,
|
| 14 |
+
pas de JS, anti-injection systématique.
|
| 15 |
+
|
| 16 |
+
Vue
|
| 17 |
+
---
|
| 18 |
+
Layout horizontal de gauche à droite :
|
| 19 |
+
|
| 20 |
+
- Chaque **nœud** est un rectangle annoté du nom du module et
|
| 21 |
+
de ses types d'entrée/sortie.
|
| 22 |
+
- Chaque **arête** porte une étiquette : type d'artefact +
|
| 23 |
+
métrique principale + valeur, avec un code couleur
|
| 24 |
+
vert/jaune/rouge selon le seuil sur la valeur.
|
| 25 |
+
|
| 26 |
+
Adaptive : ``""`` si moins d'un nœud.
|
| 27 |
+
|
| 28 |
+
Note d'intégration
|
| 29 |
+
------------------
|
| 30 |
+
Module pur — l'utilisateur compose les structures simples
|
| 31 |
+
``nodes`` et ``edges`` depuis sa ``PipelineSpec`` (Sprint 63)
|
| 32 |
+
et son ``PipelineBenchmarkResult`` (Sprint 64) :
|
| 33 |
+
|
| 34 |
+
.. code-block:: python
|
| 35 |
+
|
| 36 |
+
from picarones.report.pipeline_dag_render import build_pipeline_dag_html
|
| 37 |
+
|
| 38 |
+
nodes = [
|
| 39 |
+
{"name": s.name, "input_types": [t.value for t in s.module.input_types],
|
| 40 |
+
"output_types": [t.value for t in s.module.output_types]}
|
| 41 |
+
for s in spec.steps
|
| 42 |
+
]
|
| 43 |
+
edges = []
|
| 44 |
+
for prev, curr in zip(spec.steps, spec.steps[1:]):
|
| 45 |
+
agg = bench.aggregate_for_step(curr.name)
|
| 46 |
+
for art_type, metrics in (agg.junction_metrics or {}).items():
|
| 47 |
+
for metric_name, value in metrics.items():
|
| 48 |
+
edges.append({
|
| 49 |
+
"from": prev.name, "to": curr.name,
|
| 50 |
+
"artifact_type": art_type, "metric_name": metric_name,
|
| 51 |
+
"metric_value": value.get("mean"),
|
| 52 |
+
})
|
| 53 |
+
html = build_pipeline_dag_html(nodes, edges, labels)
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
from __future__ import annotations
|
| 57 |
+
|
| 58 |
+
from html import escape as _e
|
| 59 |
+
from typing import Optional
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# Seuils par défaut sur les métriques d'erreur (CER-like, lower is better).
|
| 63 |
+
_DEFAULT_THRESHOLDS = (0.05, 0.15) # vert ≤ 0.05, jaune ≤ 0.15, rouge > 0.15
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _classify_metric(
|
| 67 |
+
value: Optional[float],
|
| 68 |
+
thresholds: tuple[float, float],
|
| 69 |
+
higher_is_better: bool,
|
| 70 |
+
) -> str:
|
| 71 |
+
"""Retourne ``"green"``, ``"yellow"``, ``"red"`` ou ``"none"``."""
|
| 72 |
+
if value is None:
|
| 73 |
+
return "none"
|
| 74 |
+
try:
|
| 75 |
+
v = float(value)
|
| 76 |
+
except (TypeError, ValueError):
|
| 77 |
+
return "none"
|
| 78 |
+
low, high = thresholds
|
| 79 |
+
if higher_is_better:
|
| 80 |
+
# Inversion : haut = bon
|
| 81 |
+
if v >= 1.0 - low:
|
| 82 |
+
return "green"
|
| 83 |
+
if v >= 1.0 - high:
|
| 84 |
+
return "yellow"
|
| 85 |
+
return "red"
|
| 86 |
+
if v <= low:
|
| 87 |
+
return "green"
|
| 88 |
+
if v <= high:
|
| 89 |
+
return "yellow"
|
| 90 |
+
return "red"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
_QUALITY_COLORS = {
|
| 94 |
+
"green": "#16a34a",
|
| 95 |
+
"yellow": "#d97706",
|
| 96 |
+
"red": "#dc2626",
|
| 97 |
+
"none": "#6b7280",
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _format_value(value: Optional[float]) -> str:
|
| 102 |
+
if value is None:
|
| 103 |
+
return "—"
|
| 104 |
+
try:
|
| 105 |
+
v = float(value)
|
| 106 |
+
except (TypeError, ValueError):
|
| 107 |
+
return "—"
|
| 108 |
+
if abs(v) < 1.0:
|
| 109 |
+
return f"{v * 100:.1f}%"
|
| 110 |
+
return f"{v:.2f}"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def build_pipeline_dag_html(
|
| 114 |
+
nodes: Optional[list[dict]],
|
| 115 |
+
labels: Optional[dict[str, str]] = None,
|
| 116 |
+
edges: Optional[list[dict]] = None,
|
| 117 |
+
*,
|
| 118 |
+
thresholds: tuple[float, float] = _DEFAULT_THRESHOLDS,
|
| 119 |
+
higher_is_better: bool = False,
|
| 120 |
+
) -> str:
|
| 121 |
+
"""Construit la vue HTML « Pipeline DAG ».
|
| 122 |
+
|
| 123 |
+
Parameters
|
| 124 |
+
----------
|
| 125 |
+
nodes:
|
| 126 |
+
Liste de dicts ``{"name", "input_types"?, "output_types"?}``
|
| 127 |
+
dans l'ordre topologique. Si vide ou ``None``, retourne
|
| 128 |
+
``""``.
|
| 129 |
+
labels:
|
| 130 |
+
Dict i18n. Clés sous le préfixe ``dag_*``.
|
| 131 |
+
edges:
|
| 132 |
+
Liste de dicts ``{"from", "to", "artifact_type"?,
|
| 133 |
+
"metric_name"?, "metric_value"?}``. Optionnel —
|
| 134 |
+
auto-déduit séquentiel sinon.
|
| 135 |
+
thresholds:
|
| 136 |
+
``(seuil_vert, seuil_jaune)`` sur la valeur de métrique.
|
| 137 |
+
Défaut ``(0.05, 0.15)`` — convention CER.
|
| 138 |
+
higher_is_better:
|
| 139 |
+
Si ``True``, la sémantique est inversée (1 = meilleur).
|
| 140 |
+
"""
|
| 141 |
+
nodes = list(nodes or [])
|
| 142 |
+
if not nodes:
|
| 143 |
+
return ""
|
| 144 |
+
edges = list(edges or [])
|
| 145 |
+
labels = labels or {}
|
| 146 |
+
title = labels.get("dag_title", "Pipeline DAG")
|
| 147 |
+
note = labels.get(
|
| 148 |
+
"dag_note",
|
| 149 |
+
"Graphe orienté du pipeline composé. Chaque arête porte "
|
| 150 |
+
"le type d'artefact transmis et la métrique calculée à "
|
| 151 |
+
"la jonction. Code couleur vert/orange/rouge selon le "
|
| 152 |
+
"seuil. Outil d'inspection — le YAML reste source de "
|
| 153 |
+
"vérité.",
|
| 154 |
+
)
|
| 155 |
+
# Layout horizontal régulier
|
| 156 |
+
n = len(nodes)
|
| 157 |
+
box_width = 160
|
| 158 |
+
box_height = 70
|
| 159 |
+
h_gap = 110 # espace horizontal entre nœuds
|
| 160 |
+
margin = 30
|
| 161 |
+
svg_width = margin * 2 + n * box_width + (n - 1) * h_gap
|
| 162 |
+
svg_height = box_height + margin * 2 + 60 # +60 pour étiquettes arêtes
|
| 163 |
+
centre_y = margin + box_height / 2 + 30 # offset pour étiquette de tête
|
| 164 |
+
|
| 165 |
+
# Index des nœuds par name pour récupérer la position
|
| 166 |
+
node_x: dict[str, float] = {}
|
| 167 |
+
parts: list[str] = [
|
| 168 |
+
'<section class="dag-section" style="margin:1rem 0">',
|
| 169 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 170 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
|
| 171 |
+
f'{_e(note)}</div>',
|
| 172 |
+
f'<svg viewBox="0 0 {svg_width} {svg_height}" '
|
| 173 |
+
f'role="img" aria-label="{_e(title)}" '
|
| 174 |
+
'xmlns="http://www.w3.org/2000/svg" '
|
| 175 |
+
'style="max-width:100%;height:auto;'
|
| 176 |
+
'font-family:system-ui,sans-serif;font-size:12px">',
|
| 177 |
+
# Définition d'une flèche
|
| 178 |
+
'<defs>'
|
| 179 |
+
'<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" '
|
| 180 |
+
'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
|
| 181 |
+
'<path d="M0,0 L10,5 L0,10 z" fill="#374151"/>'
|
| 182 |
+
'</marker>'
|
| 183 |
+
'</defs>',
|
| 184 |
+
]
|
| 185 |
+
|
| 186 |
+
# Étape 1 : nœuds
|
| 187 |
+
for i, node in enumerate(nodes):
|
| 188 |
+
name = str(node.get("name") or f"step_{i}")
|
| 189 |
+
x = margin + i * (box_width + h_gap)
|
| 190 |
+
y = margin + 30
|
| 191 |
+
node_x[name] = x + box_width
|
| 192 |
+
in_types = ", ".join(node.get("input_types") or [])
|
| 193 |
+
out_types = ", ".join(node.get("output_types") or [])
|
| 194 |
+
parts.append(
|
| 195 |
+
f'<rect x="{x}" y="{y}" width="{box_width}" '
|
| 196 |
+
f'height="{box_height}" rx="6" fill="#f3f4f6" '
|
| 197 |
+
f'stroke="#374151" stroke-width="1.5"/>'
|
| 198 |
+
)
|
| 199 |
+
parts.append(
|
| 200 |
+
f'<text x="{x + box_width / 2}" y="{y + 22}" '
|
| 201 |
+
f'text-anchor="middle" font-weight="600" '
|
| 202 |
+
f'fill="#111827">{_e(name)}</text>'
|
| 203 |
+
)
|
| 204 |
+
if in_types:
|
| 205 |
+
parts.append(
|
| 206 |
+
f'<text x="{x + box_width / 2}" y="{y + 40}" '
|
| 207 |
+
f'text-anchor="middle" fill="#4b5563" '
|
| 208 |
+
f'font-size="10">in: {_e(in_types)}</text>'
|
| 209 |
+
)
|
| 210 |
+
if out_types:
|
| 211 |
+
parts.append(
|
| 212 |
+
f'<text x="{x + box_width / 2}" y="{y + 56}" '
|
| 213 |
+
f'text-anchor="middle" fill="#4b5563" '
|
| 214 |
+
f'font-size="10">out: {_e(out_types)}</text>'
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
# Étape 2 : arêtes (mappées sur paires séquentielles si pas de
|
| 218 |
+
# "from"/"to" explicites — voir nodes par défaut)
|
| 219 |
+
auto_edges: list[dict] = []
|
| 220 |
+
if not edges:
|
| 221 |
+
for prev, curr in zip(nodes, nodes[1:]):
|
| 222 |
+
auto_edges.append({
|
| 223 |
+
"from": prev.get("name"),
|
| 224 |
+
"to": curr.get("name"),
|
| 225 |
+
})
|
| 226 |
+
else:
|
| 227 |
+
auto_edges = edges
|
| 228 |
+
|
| 229 |
+
for edge in auto_edges:
|
| 230 |
+
src = str(edge.get("from") or "")
|
| 231 |
+
dst = str(edge.get("to") or "")
|
| 232 |
+
if not src or not dst:
|
| 233 |
+
continue
|
| 234 |
+
# Position : du bord droit du src au bord gauche du dst
|
| 235 |
+
# Heuristique : on prend la position du nœud src dans la
|
| 236 |
+
# liste pour calculer x1, et celle de dst pour x2.
|
| 237 |
+
try:
|
| 238 |
+
i_src = next(
|
| 239 |
+
i for i, n_ in enumerate(nodes)
|
| 240 |
+
if n_.get("name") == src
|
| 241 |
+
)
|
| 242 |
+
i_dst = next(
|
| 243 |
+
i for i, n_ in enumerate(nodes)
|
| 244 |
+
if n_.get("name") == dst
|
| 245 |
+
)
|
| 246 |
+
except StopIteration:
|
| 247 |
+
continue
|
| 248 |
+
x1 = margin + i_src * (box_width + h_gap) + box_width
|
| 249 |
+
x2 = margin + i_dst * (box_width + h_gap)
|
| 250 |
+
y = centre_y
|
| 251 |
+
# Classe la métrique pour le code couleur
|
| 252 |
+
value = edge.get("metric_value")
|
| 253 |
+
try:
|
| 254 |
+
value_f = float(value) if value is not None else None
|
| 255 |
+
except (TypeError, ValueError):
|
| 256 |
+
value_f = None
|
| 257 |
+
cls = _classify_metric(value_f, thresholds, higher_is_better)
|
| 258 |
+
color = _QUALITY_COLORS[cls]
|
| 259 |
+
# Trace la flèche
|
| 260 |
+
parts.append(
|
| 261 |
+
f'<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" '
|
| 262 |
+
f'stroke="{color}" stroke-width="2" '
|
| 263 |
+
f'marker-end="url(#arrow)"/>'
|
| 264 |
+
)
|
| 265 |
+
# Étiquette : type + métrique : valeur
|
| 266 |
+
artifact_type = edge.get("artifact_type") or ""
|
| 267 |
+
metric_name = edge.get("metric_name") or ""
|
| 268 |
+
value_str = _format_value(value_f)
|
| 269 |
+
label_lines: list[str] = []
|
| 270 |
+
if artifact_type:
|
| 271 |
+
label_lines.append(str(artifact_type))
|
| 272 |
+
if metric_name:
|
| 273 |
+
label_lines.append(f"{metric_name}: {value_str}")
|
| 274 |
+
if label_lines:
|
| 275 |
+
label_x = (x1 + x2) / 2
|
| 276 |
+
for k, line in enumerate(label_lines):
|
| 277 |
+
parts.append(
|
| 278 |
+
f'<text x="{label_x}" y="{y - 8 - k * 12}" '
|
| 279 |
+
f'text-anchor="middle" fill="{color}" '
|
| 280 |
+
f'font-size="10" font-weight="600">'
|
| 281 |
+
f'{_e(line)}</text>'
|
| 282 |
+
)
|
| 283 |
+
parts.append("</svg>")
|
| 284 |
+
|
| 285 |
+
# Légende
|
| 286 |
+
h_legend = labels.get("dag_legend", "Lecture")
|
| 287 |
+
legend_green = labels.get("dag_legend_green", "qualité élevée")
|
| 288 |
+
legend_yellow = labels.get("dag_legend_yellow", "qualité moyenne")
|
| 289 |
+
legend_red = labels.get("dag_legend_red", "qualité faible")
|
| 290 |
+
parts.append(
|
| 291 |
+
'<div style="font-size:.8rem;opacity:.75;margin-top:.4rem">'
|
| 292 |
+
f'<strong>{_e(h_legend)} :</strong> '
|
| 293 |
+
f'<span style="color:{_QUALITY_COLORS["green"]};'
|
| 294 |
+
f'font-weight:600">●</span> {_e(legend_green)} '
|
| 295 |
+
f'(≤ {thresholds[0] * 100:.0f}%) '
|
| 296 |
+
f'<span style="color:{_QUALITY_COLORS["yellow"]};'
|
| 297 |
+
f'font-weight:600">●</span> {_e(legend_yellow)} '
|
| 298 |
+
f'(≤ {thresholds[1] * 100:.0f}%) '
|
| 299 |
+
f'<span style="color:{_QUALITY_COLORS["red"]};'
|
| 300 |
+
f'font-weight:600">●</span> {_e(legend_red)}'
|
| 301 |
+
'</div>'
|
| 302 |
+
)
|
| 303 |
+
parts.append("</section>")
|
| 304 |
+
return "".join(parts)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
__all__ = ["build_pipeline_dag_html"]
|
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 95 — B.4 : visualisation DAG d'un pipeline composé.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``build_pipeline_dag_html`` :
|
| 6 |
+
- vide / None → ``""``
|
| 7 |
+
- 1 nœud → SVG sans arête
|
| 8 |
+
- 2 nœuds + 1 arête
|
| 9 |
+
- 3 nœuds chaînés
|
| 10 |
+
- arêtes auto-déduites si non fournies
|
| 11 |
+
- couleur selon seuil de la métrique
|
| 12 |
+
- mode higher_is_better
|
| 13 |
+
2. Anti-injection sur nom de nœud, type d'artefact, nom de
|
| 14 |
+
métrique.
|
| 15 |
+
3. Affichage de la valeur de métrique formatée.
|
| 16 |
+
4. Complétude i18n FR/EN.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from picarones.report.pipeline_dag_render import build_pipeline_dag_html
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _load_labels(lang: str) -> dict:
|
| 28 |
+
p = (
|
| 29 |
+
Path(__file__).parent.parent
|
| 30 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 31 |
+
)
|
| 32 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 36 |
+
# 1. build_pipeline_dag_html
|
| 37 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class TestRender:
|
| 41 |
+
def test_empty_returns_empty(self) -> None:
|
| 42 |
+
assert build_pipeline_dag_html(None) == ""
|
| 43 |
+
assert build_pipeline_dag_html([]) == ""
|
| 44 |
+
|
| 45 |
+
def test_single_node_renders_svg_no_edge(self) -> None:
|
| 46 |
+
nodes = [{"name": "tess", "output_types": ["TEXT"]}]
|
| 47 |
+
html = build_pipeline_dag_html(nodes, _load_labels("fr"))
|
| 48 |
+
assert "<svg" in html
|
| 49 |
+
assert "tess" in html
|
| 50 |
+
# Pas de flèche tracée (pas d'arête)
|
| 51 |
+
assert "marker-end" not in html
|
| 52 |
+
|
| 53 |
+
def test_two_nodes_one_edge(self) -> None:
|
| 54 |
+
nodes = [
|
| 55 |
+
{"name": "ocr", "output_types": ["TEXT"]},
|
| 56 |
+
{"name": "llm", "input_types": ["TEXT"]},
|
| 57 |
+
]
|
| 58 |
+
edges = [{"from": "ocr", "to": "llm",
|
| 59 |
+
"artifact_type": "TEXT",
|
| 60 |
+
"metric_name": "cer",
|
| 61 |
+
"metric_value": 0.04}]
|
| 62 |
+
html = build_pipeline_dag_html(
|
| 63 |
+
nodes, _load_labels("fr"), edges=edges,
|
| 64 |
+
)
|
| 65 |
+
# Nœuds présents
|
| 66 |
+
assert "ocr" in html
|
| 67 |
+
assert "llm" in html
|
| 68 |
+
# Étiquettes d'arête
|
| 69 |
+
assert "TEXT" in html
|
| 70 |
+
assert "cer" in html
|
| 71 |
+
assert "4.0%" in html
|
| 72 |
+
# Flèche présente
|
| 73 |
+
assert "marker-end" in html
|
| 74 |
+
|
| 75 |
+
def test_three_nodes_chain(self) -> None:
|
| 76 |
+
nodes = [
|
| 77 |
+
{"name": "a"}, {"name": "b"}, {"name": "c"},
|
| 78 |
+
]
|
| 79 |
+
edges = [
|
| 80 |
+
{"from": "a", "to": "b", "metric_value": 0.05},
|
| 81 |
+
{"from": "b", "to": "c", "metric_value": 0.10},
|
| 82 |
+
]
|
| 83 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 84 |
+
# Deux flèches
|
| 85 |
+
assert html.count("marker-end") == 2
|
| 86 |
+
|
| 87 |
+
def test_auto_edges_when_missing(self) -> None:
|
| 88 |
+
# Pas d'arêtes fournies → auto-déduit séquentielles
|
| 89 |
+
nodes = [{"name": "a"}, {"name": "b"}, {"name": "c"}]
|
| 90 |
+
html = build_pipeline_dag_html(nodes)
|
| 91 |
+
assert html.count("marker-end") == 2
|
| 92 |
+
|
| 93 |
+
def test_colour_green_for_low_cer(self) -> None:
|
| 94 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 95 |
+
edges = [{"from": "a", "to": "b",
|
| 96 |
+
"metric_value": 0.02}] # ≤ 0.05 → vert
|
| 97 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 98 |
+
assert "#16a34a" in html # green
|
| 99 |
+
|
| 100 |
+
def test_colour_yellow(self) -> None:
|
| 101 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 102 |
+
edges = [{"from": "a", "to": "b", "metric_value": 0.10}]
|
| 103 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 104 |
+
assert "#d97706" in html # yellow
|
| 105 |
+
|
| 106 |
+
def test_colour_red_for_high_cer(self) -> None:
|
| 107 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 108 |
+
edges = [{"from": "a", "to": "b", "metric_value": 0.30}]
|
| 109 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 110 |
+
assert "#dc2626" in html # red
|
| 111 |
+
|
| 112 |
+
def test_higher_is_better_inverts(self) -> None:
|
| 113 |
+
# F1 = 0.95 = bonne qualité (haut)
|
| 114 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 115 |
+
edges = [{"from": "a", "to": "b", "metric_value": 0.96}]
|
| 116 |
+
html = build_pipeline_dag_html(
|
| 117 |
+
nodes, edges=edges, higher_is_better=True,
|
| 118 |
+
)
|
| 119 |
+
assert "#16a34a" in html
|
| 120 |
+
|
| 121 |
+
def test_unknown_node_in_edge_skipped(self) -> None:
|
| 122 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 123 |
+
edges = [
|
| 124 |
+
{"from": "a", "to": "b", "metric_value": 0.05},
|
| 125 |
+
{"from": "ghost", "to": "b", "metric_value": 0.01},
|
| 126 |
+
]
|
| 127 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 128 |
+
# Une seule flèche valide
|
| 129 |
+
assert html.count("marker-end") == 1
|
| 130 |
+
|
| 131 |
+
def test_handles_missing_metric_value(self) -> None:
|
| 132 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 133 |
+
edges = [{"from": "a", "to": "b",
|
| 134 |
+
"artifact_type": "TEXT",
|
| 135 |
+
"metric_name": "cer"}] # pas de valeur
|
| 136 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 137 |
+
assert "—" in html or "cer" in html
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 141 |
+
# 2. Anti-injection
|
| 142 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class TestAntiInjection:
|
| 146 |
+
def test_node_name(self) -> None:
|
| 147 |
+
nodes = [{"name": "<script>alert(1)</script>"}]
|
| 148 |
+
html = build_pipeline_dag_html(nodes, _load_labels("fr"))
|
| 149 |
+
assert "<script>alert" not in html
|
| 150 |
+
assert "<script>" in html
|
| 151 |
+
|
| 152 |
+
def test_artifact_type(self) -> None:
|
| 153 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 154 |
+
edges = [{"from": "a", "to": "b",
|
| 155 |
+
"artifact_type": "<img/>",
|
| 156 |
+
"metric_value": 0.05}]
|
| 157 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 158 |
+
assert "<img/>" not in html
|
| 159 |
+
assert "<img" in html
|
| 160 |
+
|
| 161 |
+
def test_metric_name(self) -> None:
|
| 162 |
+
nodes = [{"name": "a"}, {"name": "b"}]
|
| 163 |
+
edges = [{"from": "a", "to": "b",
|
| 164 |
+
"metric_name": "<script>x",
|
| 165 |
+
"metric_value": 0.05}]
|
| 166 |
+
html = build_pipeline_dag_html(nodes, edges=edges)
|
| 167 |
+
assert "<script>x" not in html
|
| 168 |
+
assert "<script>" in html
|
| 169 |
+
|
| 170 |
+
def test_input_output_types(self) -> None:
|
| 171 |
+
nodes = [{"name": "a", "input_types": ["<svg/>"],
|
| 172 |
+
"output_types": ["<x>"]}]
|
| 173 |
+
html = build_pipeline_dag_html(nodes, _load_labels("fr"))
|
| 174 |
+
assert "<svg/>" not in html
|
| 175 |
+
assert "<svg" in html
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 179 |
+
# 3. Rendu en anglais
|
| 180 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class TestI18nRendering:
|
| 184 |
+
def test_english(self) -> None:
|
| 185 |
+
nodes = [{"name": "a"}]
|
| 186 |
+
html = build_pipeline_dag_html(nodes, _load_labels("en"))
|
| 187 |
+
assert "Inspection tool" in html or "source of truth" in html
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 191 |
+
# 4. Complétude i18n
|
| 192 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
_KEYS = {
|
| 196 |
+
"dag_title", "dag_note", "dag_legend",
|
| 197 |
+
"dag_legend_green", "dag_legend_yellow", "dag_legend_red",
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
class TestI18nCompleteness:
|
| 202 |
+
def test_fr(self) -> None:
|
| 203 |
+
d = _load_labels("fr")
|
| 204 |
+
assert not _KEYS - d.keys()
|
| 205 |
+
|
| 206 |
+
def test_en(self) -> None:
|
| 207 |
+
d = _load_labels("en")
|
| 208 |
+
assert not _KEYS - d.keys()
|