Claude commited on
Commit
94e0210
·
unverified ·
1 Parent(s): 03e7c21

sprint95: B.4 - visualisation DAG d'un pipeline composé (SVG server-side)

Browse files

Outil 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 CHANGED
@@ -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
CLAUDE.md CHANGED
@@ -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** : 3037 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)**)
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** :
picarones/report/i18n/en.json CHANGED
@@ -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
  }
picarones/report/i18n/fr.json CHANGED
@@ -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
  }
picarones/report/pipeline_dag_render.py ADDED
@@ -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"]
tests/test_sprint95_pipeline_dag.py ADDED
@@ -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 "&lt;script&gt;" 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 "&lt;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 "&lt;script&gt;" 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 "&lt;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()