Spaces:
Running
sprint63: banc d'essai de pipelines composées (démarrage axe B)
Browse filesPicarones reste un banc d'essai, pas un atelier de production. Ce
sprint livre l'infrastructure qui permet d'évaluer des pipelines
composées de modules tiers que l'utilisateur amène (ses propres
BaseModule Sprint 33), sans qu'aucun module métier ne soit fourni
par Picarones (pas de reconstructeur ALTO, pas de correcteur LLM).
- Nouveau module picarones/core/pipeline_runner.py :
- PipelineStep(name, module) : lit input_types/output_types du module.
- PipelineSpec(name, steps) : DAG séquentiel + validate() statique.
- StepResult / PipelineResult : durée, output_types, junction_metrics,
error, succeeded, failing_steps, junction_metrics_for() qui ignore
les étapes en erreur.
- PipelineRunner.run(spec, document, initial_inputs) : exécute
mono-document, valide entrées disponibles, chronomètre wall-clock,
capture gracieusement les exceptions, valide sorties produites,
et évalue automatiquement chaque type produit contre la GT du
même niveau (Sprint 32) via compute_at_junction (Sprint 34).
- Eager-load des registres de métriques au top du module pour que
compute_at_junction trouve toutes les métriques.
- Périmètre Sprint 63 : séquentiel mono-document. DAG branchant,
parallélisation, agrégation corpus-wide et vue HTML reportés à
des sprints suivants de l'axe B.
- +16 tests dans test_sprint63_pipeline_runner.py (validation,
exécution 1 et 2 étapes, erreurs gracieuses sur 3 cas, pas de GT,
mesure du temps, dataclasses).
- Tous les modules utilisés dans les tests sont des mocks définis
dans le fichier de test (MockOCR, MockTextRewriter, MockCrasher,
MockSilentDropper) — Picarones n'expose volontairement aucun
module métier.
Tests : 2350 passed, 2 skipped, 0 failed.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +70 -0
- CLAUDE.md +2 -1
- picarones/core/pipeline_runner.py +489 -0
- tests/test_sprint63_pipeline_runner.py +395 -0
|
@@ -16,6 +16,76 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 62 — Vue HTML « Profil philologique » (clôture du
|
| 20 |
câblage philologique bout-en-bout).** Suite directe Sprint 61
|
| 21 |
(câblage backend) — produit le bloc HTML qui remonte les six
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 63 — Banc d'essai de pipelines composées : runner +
|
| 20 |
+
évaluation aux jonctions (démarrage axe B du plan 2026).**
|
| 21 |
+
Picarones est et reste un **banc d'essai**, pas un atelier de
|
| 22 |
+
production : ce sprint livre l'infrastructure qui permet
|
| 23 |
+
d'**évaluer des pipelines composées de modules tiers** que
|
| 24 |
+
l'utilisateur amène (ses propres ``BaseModule`` du Sprint 33),
|
| 25 |
+
**sans qu'aucun module métier ne soit fourni par Picarones**
|
| 26 |
+
(pas de reconstructeur ALTO, pas de correcteur LLM, pas de
|
| 27 |
+
re-segmenteur).
|
| 28 |
+
- Nouveau module `picarones/core/pipeline_runner.py` :
|
| 29 |
+
- ``PipelineStep(name, module)`` : une étape lit ses
|
| 30 |
+
``input_types`` / ``output_types`` directement depuis le
|
| 31 |
+
``BaseModule`` fourni par l'utilisateur.
|
| 32 |
+
- ``PipelineSpec(name, steps)`` : DAG séquentiel de
|
| 33 |
+
``PipelineStep`` avec validation statique des types
|
| 34 |
+
(``validate(initial_inputs)`` retourne la liste des
|
| 35 |
+
problèmes ; ``is_valid`` raccourci booléen).
|
| 36 |
+
- ``StepResult(step_name, duration_seconds, output_types,
|
| 37 |
+
junction_metrics, error)`` : résultat d'une étape avec
|
| 38 |
+
durée chronométrée, types effectivement produits, métriques
|
| 39 |
+
aux jonctions et erreur éventuelle.
|
| 40 |
+
- ``PipelineResult(pipeline_name, doc_id, steps,
|
| 41 |
+
total_duration_seconds, error)`` : résultat complet pour un
|
| 42 |
+
document, avec ``succeeded``, ``failing_steps``, et
|
| 43 |
+
``junction_metrics_for(artifact_type)`` qui retourne les
|
| 44 |
+
métriques de la **dernière étape réussie** ayant produit le
|
| 45 |
+
type demandé.
|
| 46 |
+
- ``PipelineRunner.run(spec, document, initial_inputs)`` :
|
| 47 |
+
exécute la pipeline sur **un seul document**. À chaque
|
| 48 |
+
étape : valide les entrées disponibles, exécute le module
|
| 49 |
+
avec chronométrage wall-clock, capture gracieusement les
|
| 50 |
+
exceptions (``RuntimeError``, etc.), valide que les sorties
|
| 51 |
+
déclarées sont effectivement produites, met à jour le bag
|
| 52 |
+
d'artefacts disponibles, et **évalue automatiquement chaque
|
| 53 |
+
type produit contre la GT du même niveau** (Sprint 32) via
|
| 54 |
+
``compute_at_junction`` (Sprint 34) — sélectionnant les
|
| 55 |
+
métriques pertinentes selon les types.
|
| 56 |
+
- **Eager-load** des modules de métriques au top du
|
| 57 |
+
``pipeline_runner.py`` (``builtin_metrics``, les six modules
|
| 58 |
+
philologiques, NER, reading_order, readability) pour garantir
|
| 59 |
+
que le registre typé soit peuplé avant l'évaluation aux
|
| 60 |
+
jonctions — sans ça, le runner trouverait un registre vide.
|
| 61 |
+
- **Périmètre Sprint 63** : runner séquentiel mono-document.
|
| 62 |
+
DAG branchant, parallélisation, agrégation corpus-wide et
|
| 63 |
+
vue HTML dédiée aux pipelines sont reportés à des sprints
|
| 64 |
+
dédiés.
|
| 65 |
+
- +16 tests dans `test_sprint63_pipeline_runner.py` :
|
| 66 |
+
validation de spec (vide, chaînée, manque d'entrée),
|
| 67 |
+
exécution 1 étape (parfait + imparfait), exécution 2 étapes
|
| 68 |
+
avec évaluation à chaque jonction et CER qui baisse après
|
| 69 |
+
correction par le rewriter, erreurs gracieuses (module qui
|
| 70 |
+
lève → RuntimeError capturé sans arrêter la chaîne ; module
|
| 71 |
+
silencieux qui ne produit pas la sortie déclarée → erreur
|
| 72 |
+
explicite ; spec invalide → erreur en amont, aucune étape
|
| 73 |
+
exécutée), pas de GT → pas de métriques sans erreur, mesure
|
| 74 |
+
du temps par étape, dataclasses (``StepResult`` /
|
| 75 |
+
``PipelineResult.succeeded`` / ``failing_steps`` /
|
| 76 |
+
``junction_metrics_for`` qui ignore les étapes en erreur).
|
| 77 |
+
- **Tous les modules utilisés dans les tests sont des mocks
|
| 78 |
+
définis dans le fichier de test** (``MockOCR``,
|
| 79 |
+
``MockTextRewriter``, ``MockCrasher``, ``MockSilentDropper``)
|
| 80 |
+
— Picarones n'expose volontairement aucun module métier.
|
| 81 |
+
- **Verrou levé** : l'utilisateur peut désormais brancher ses
|
| 82 |
+
propres modules tiers (un correcteur LLM, un reconstructeur
|
| 83 |
+
ALTO, un re-segmenteur, un classifieur d'entités), composer
|
| 84 |
+
une pipeline et obtenir automatiquement les métriques à
|
| 85 |
+
chaque étape contre la GT correspondante. L'orchestration
|
| 86 |
+
corpus-wide et la vue HTML dédiée arrivent dans les sprints
|
| 87 |
+
suivants de l'axe B.
|
| 88 |
+
|
| 89 |
- **Sprint 62 — Vue HTML « Profil philologique » (clôture du
|
| 90 |
câblage philologique bout-en-bout).** Suite directe Sprint 61
|
| 91 |
(câblage backend) — produit le bloc HTML qui remonte les six
|
|
@@ -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 |
| 62 | **Sprint 31 du plan d'évolution 2026 — Étape 3 / vue HTML « Profil philologique » (clôture câblage philologique bout-en-bout)**. Suite directe Sprint 61 (câblage backend) — produit le bloc HTML qui remonte les six modules philologiques (Sprints 55-60) dans le rapport. Pattern identique aux Sprints 41 (NER) et 43 (calibration) : rendu server-side, pas de JS, déterministe. Nouveau module `picarones/report/philological_render.py` : 6 fonctions de rendu de section (`build_unicode_blocks_section`, `build_abbreviations_section`, `build_mufi_section`, `build_early_modern_section`, `build_modern_archives_section`, `build_roman_numerals_section`) + agrégateur `build_philological_profile_html` qui assemble en un bloc unique avec note explicite « L'outil ne classifie pas la convention adoptée par chaque moteur — c'est au chercheur de lire les chiffres et de conclure selon ses critères éditoriaux ». **Adaptive masking complet** : chaque section conditionnée à la présence de signal sur ≥ 1 moteur ; agrégateur retourne `""` si aucun signal global. Cellules colorées par gradient rouge→vert proportionnel au score (sémantique inversée pour `lost` des numéraux : haut taux = rouge). Effectifs `n=…` affichés à côté de chaque score. Câblage `ReportGenerator.generate` + `view_analyses.html` (chart-card pleine largeur conditionné). Anti-injection HTML systématique via `html.escape`. **Aucune classification automatique** : `diplomatique`/`modernisant` n'apparaît que dans la note d'usage, jamais accolé à un moteur. +25 clés i18n FR/EN (`philo_profile_*`, `philo_unicode_*`, `philo_abbreviations_*`, `philo_mufi_*`, `philo_early_modern_*`, `philo_modern_archives_*`, `philo_roman_numerals_*`, `philo_roman_status_*`). +18 tests dans `test_sprint62_philological_html.py` (sections ×6, adaptive masking, anti-injection sur nom moteur + libellé i18n, %, code couleur, pas de classification imposée, complétude i18n). **Verrou levé** : les six modules philologiques sont livrés bout-en-bout (calcul Sprints 55-60 + backend Sprint 61 + HTML Sprint 62). Un benchmark sur n'importe quel fonds patrimonial européen produit automatiquement, sans configuration, un profil philologique lisible dans le rapport — donné par catégorie/bloc/statut, sans verdict. |
|
| 211 |
| 61 | **Sprint 30 du plan d'évolution 2026 — Étape 3 / câblage backend des métriques philologiques au runner (Sprints 55-60)**. Suite directe Sprints 55-60. Les six modules philologiques sont désormais calculés automatiquement par le runner pour chaque document et agrégés par moteur, sans aucune option à activer. Nouveau module `picarones/core/philological_runner.py` : `compute_philological_metrics(reference, hypothesis)` calcule les six modules avec **adaptive masking** (un module n'apparaît que si la GT a du signal exploitable : `n_markers_reference > 0`, `n_mufi_chars_reference > 0`, au moins un caractère hors Basic Latin pour unicode_blocks…) ; `aggregate_philological_metrics(per_doc_list)` agrège les compteurs bruts par module (somme), recalcule les scores globaux, et préserve les structures `per_block`/`per_abbreviation`/`per_char`/`per_category`/`per_status` agrégées. Nouveaux champs `DocumentResult.philological_metrics` et `EngineReport.aggregated_philological` (`Optional[dict]`, sérialisés conditionnellement, libérés par `compact`). Câblage runner : calcul inconditionnel (coût O(N) sur texte, négligeable face à l'OCR), erreur d'un module individuel n'arrête pas les autres + warning explicite. Rétrocompat stricte : aucun paramètre ajouté, comportement existant inchangé, un benchmark sans signal philologique n'a aucun champ ajouté au JSON. +24 tests dans `test_sprint61_philological_runner.py` (champs, sérialisation/compact, calcul adaptive sur 6 cas — médiéval/imprimé/moderne/romain/diacritiques/ASCII pur, agrégation des compteurs et recalcul des scores globaux, intégration runner end-to-end avec mock). **Verrou levé** : les six modules philologiques sont désormais visibles dans le pipeline standard de bench, il manque la vue HTML dédiée (Sprint 62). |
|
| 212 |
| 60 | **Sprint 29 du plan d'évolution 2026 — Étape 3 / extension philologique transversale : numéraux romains (couche de calcul, clôture extension par période)**. Suite directe Sprints 56-59. Les numéraux romains traversent les trois périodes patrimoniales — médiéval (minuscules + j final `mcclxxxij`=1282), imprimé ancien (`Tome IV`), moderne (`Louis XIV`, `MCMXIV`). Module `picarones/core/roman_numerals.py` : `roman_to_int` parsing tolérant casse + j médiéval avec validation stricte des paires soustractives canoniques (IV, IX, XL, XC, CD, CM seulement — rejette `ICI`, `IL`, `VV`, `IIIII`), forme additive médiévale `IIII` acceptée, `int_to_roman` canonique, `detect_roman_numerals(text, min_length=1)` avec filtre paramétrable contre les single-letter ambigus (`I` pronom). `compute_roman_numeral_metrics` classifie chaque numéral GT en **5 statuts ordonnés par priorité** : `strict_preserved` (forme exacte), `case_changed` (valeur OK casse différente), `j_dropped` (j médiéval normalisé en i), `converted_to_arabic` (XIV→14), `lost`. Retourne `per_status`, `per_numeral`, `lost_numerals`, `global_strict_score`, `global_value_score` (toute forme préservant la valeur). `roman_numeral_strict_score` et `roman_numeral_value_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Choix éditorial assumé identique aux Sprints 58-59** : pas de classification automatique — le chercheur lit `per_status` et juge la convention. +93 tests (parsing paramétrée standard + minuscules + j médiéval, formes invalides rejetées, aller-retour, détection avec min_length et frontière de mot anti-`VIVE`, **rejet du faux positif `ICI`**, 5 statuts individuellement, priorité strict>arabic, **3 cas réalistes par période** — charte médiévale, imprimé ancien, souverain moderne —, comptage exhaustif somme des per_status = total, dégénérés, raccourcis, intégration registre). **Verrou levé** : l'extension philologique transversale est intégralement livrée — un benchmark sur n'importe quel fonds patrimonial européen peut désormais classer les moteurs sur leur traitement des numéraux romains, indépendamment de la période. |
|
|
@@ -280,7 +281,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 280 |
## Contexte développement
|
| 281 |
|
| 282 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 283 |
-
- **Tests** :
|
| 284 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 285 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 286 |
- **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 |
+
| 63 | **Sprint 32 du plan d'évolution 2026 — Étape 4 / axe B : banc d'essai de pipelines composées (couche d'orchestration mono-document)**. Démarrage de l'axe B du plan 2026 — Picarones reste un **banc d'essai**, pas un atelier de production : ce sprint livre l'infrastructure qui permet d'**évaluer des pipelines composées de modules tiers** que l'utilisateur amène (ses propres `BaseModule` Sprint 33), **sans qu'aucun module métier ne soit fourni par Picarones**. Nouveau module `picarones/core/pipeline_runner.py` : `PipelineStep(name, module)` (lit les `input_types`/`output_types` du module), `PipelineSpec(name, steps)` (DAG séquentiel + `validate()`/`is_valid()` qui vérifie statiquement que les types s'enchaînent), `StepResult` (durée, output_types, junction_metrics, error), `PipelineResult` (succeeded, failing_steps, `junction_metrics_for(artifact_type)` qui ignore les étapes en erreur), `PipelineRunner.run(spec, document, initial_inputs)` qui exécute mono-document, valide les entrées disponibles, chronomètre chaque étape en wall-clock, capture gracieusement les exceptions, valide que les sorties déclarées sont produites, et **évalue automatiquement chaque type produit contre la GT du même niveau** (Sprint 32) via `compute_at_junction` (Sprint 34). Eager-load au top du module des registres de métriques (`builtin_metrics` + 6 philologiques + NER/reading_order/readability) pour garantir que `compute_at_junction` ait accès à toutes les métriques sans import explicite par l'utilisateur. **Périmètre Sprint 63** : séquentiel mono-document ; DAG branchant, parallélisation, agrégation corpus-wide et vue HTML dédiée reportés à des sprints suivants de l'axe B. +16 tests dans `test_sprint63_pipeline_runner.py` (validation de spec, exécution 1 étape parfaite/imparfaite, 2 étapes chaînées avec CER qui baisse après correction par le rewriter, erreurs gracieuses sur 3 cas — module qui lève / module silencieux / spec invalide —, pas de GT → pas de métriques sans erreur, mesure du temps, dataclasses, `junction_metrics_for` qui skippe les étapes en erreur). **Tous les modules utilisés sont des mocks définis dans le fichier de test** (MockOCR, MockTextRewriter, MockCrasher, MockSilentDropper) — Picarones n'expose volontairement aucun module métier. **Verrou levé** : l'utilisateur peut désormais brancher ses propres modules tiers (correcteur LLM, reconstructeur ALTO, re-segmenteur, classifieur d'entités), composer une pipeline et obtenir automatiquement les métriques à chaque étape contre la GT correspondante. |
|
| 211 |
| 62 | **Sprint 31 du plan d'évolution 2026 — Étape 3 / vue HTML « Profil philologique » (clôture câblage philologique bout-en-bout)**. Suite directe Sprint 61 (câblage backend) — produit le bloc HTML qui remonte les six modules philologiques (Sprints 55-60) dans le rapport. Pattern identique aux Sprints 41 (NER) et 43 (calibration) : rendu server-side, pas de JS, déterministe. Nouveau module `picarones/report/philological_render.py` : 6 fonctions de rendu de section (`build_unicode_blocks_section`, `build_abbreviations_section`, `build_mufi_section`, `build_early_modern_section`, `build_modern_archives_section`, `build_roman_numerals_section`) + agrégateur `build_philological_profile_html` qui assemble en un bloc unique avec note explicite « L'outil ne classifie pas la convention adoptée par chaque moteur — c'est au chercheur de lire les chiffres et de conclure selon ses critères éditoriaux ». **Adaptive masking complet** : chaque section conditionnée à la présence de signal sur ≥ 1 moteur ; agrégateur retourne `""` si aucun signal global. Cellules colorées par gradient rouge→vert proportionnel au score (sémantique inversée pour `lost` des numéraux : haut taux = rouge). Effectifs `n=…` affichés à côté de chaque score. Câblage `ReportGenerator.generate` + `view_analyses.html` (chart-card pleine largeur conditionné). Anti-injection HTML systématique via `html.escape`. **Aucune classification automatique** : `diplomatique`/`modernisant` n'apparaît que dans la note d'usage, jamais accolé à un moteur. +25 clés i18n FR/EN (`philo_profile_*`, `philo_unicode_*`, `philo_abbreviations_*`, `philo_mufi_*`, `philo_early_modern_*`, `philo_modern_archives_*`, `philo_roman_numerals_*`, `philo_roman_status_*`). +18 tests dans `test_sprint62_philological_html.py` (sections ×6, adaptive masking, anti-injection sur nom moteur + libellé i18n, %, code couleur, pas de classification imposée, complétude i18n). **Verrou levé** : les six modules philologiques sont livrés bout-en-bout (calcul Sprints 55-60 + backend Sprint 61 + HTML Sprint 62). Un benchmark sur n'importe quel fonds patrimonial européen produit automatiquement, sans configuration, un profil philologique lisible dans le rapport — donné par catégorie/bloc/statut, sans verdict. |
|
| 212 |
| 61 | **Sprint 30 du plan d'évolution 2026 — Étape 3 / câblage backend des métriques philologiques au runner (Sprints 55-60)**. Suite directe Sprints 55-60. Les six modules philologiques sont désormais calculés automatiquement par le runner pour chaque document et agrégés par moteur, sans aucune option à activer. Nouveau module `picarones/core/philological_runner.py` : `compute_philological_metrics(reference, hypothesis)` calcule les six modules avec **adaptive masking** (un module n'apparaît que si la GT a du signal exploitable : `n_markers_reference > 0`, `n_mufi_chars_reference > 0`, au moins un caractère hors Basic Latin pour unicode_blocks…) ; `aggregate_philological_metrics(per_doc_list)` agrège les compteurs bruts par module (somme), recalcule les scores globaux, et préserve les structures `per_block`/`per_abbreviation`/`per_char`/`per_category`/`per_status` agrégées. Nouveaux champs `DocumentResult.philological_metrics` et `EngineReport.aggregated_philological` (`Optional[dict]`, sérialisés conditionnellement, libérés par `compact`). Câblage runner : calcul inconditionnel (coût O(N) sur texte, négligeable face à l'OCR), erreur d'un module individuel n'arrête pas les autres + warning explicite. Rétrocompat stricte : aucun paramètre ajouté, comportement existant inchangé, un benchmark sans signal philologique n'a aucun champ ajouté au JSON. +24 tests dans `test_sprint61_philological_runner.py` (champs, sérialisation/compact, calcul adaptive sur 6 cas — médiéval/imprimé/moderne/romain/diacritiques/ASCII pur, agrégation des compteurs et recalcul des scores globaux, intégration runner end-to-end avec mock). **Verrou levé** : les six modules philologiques sont désormais visibles dans le pipeline standard de bench, il manque la vue HTML dédiée (Sprint 62). |
|
| 213 |
| 60 | **Sprint 29 du plan d'évolution 2026 — Étape 3 / extension philologique transversale : numéraux romains (couche de calcul, clôture extension par période)**. Suite directe Sprints 56-59. Les numéraux romains traversent les trois périodes patrimoniales — médiéval (minuscules + j final `mcclxxxij`=1282), imprimé ancien (`Tome IV`), moderne (`Louis XIV`, `MCMXIV`). Module `picarones/core/roman_numerals.py` : `roman_to_int` parsing tolérant casse + j médiéval avec validation stricte des paires soustractives canoniques (IV, IX, XL, XC, CD, CM seulement — rejette `ICI`, `IL`, `VV`, `IIIII`), forme additive médiévale `IIII` acceptée, `int_to_roman` canonique, `detect_roman_numerals(text, min_length=1)` avec filtre paramétrable contre les single-letter ambigus (`I` pronom). `compute_roman_numeral_metrics` classifie chaque numéral GT en **5 statuts ordonnés par priorité** : `strict_preserved` (forme exacte), `case_changed` (valeur OK casse différente), `j_dropped` (j médiéval normalisé en i), `converted_to_arabic` (XIV→14), `lost`. Retourne `per_status`, `per_numeral`, `lost_numerals`, `global_strict_score`, `global_value_score` (toute forme préservant la valeur). `roman_numeral_strict_score` et `roman_numeral_value_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. **Choix éditorial assumé identique aux Sprints 58-59** : pas de classification automatique — le chercheur lit `per_status` et juge la convention. +93 tests (parsing paramétrée standard + minuscules + j médiéval, formes invalides rejetées, aller-retour, détection avec min_length et frontière de mot anti-`VIVE`, **rejet du faux positif `ICI`**, 5 statuts individuellement, priorité strict>arabic, **3 cas réalistes par période** — charte médiévale, imprimé ancien, souverain moderne —, comptage exhaustif somme des per_status = total, dégénérés, raccourcis, intégration registre). **Verrou levé** : l'extension philologique transversale est intégralement livrée — un benchmark sur n'importe quel fonds patrimonial européen peut désormais classer les moteurs sur leur traitement des numéraux romains, indépendamment de la période. |
|
|
|
|
| 281 |
## Contexte développement
|
| 282 |
|
| 283 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 284 |
+
- **Tests** : 2350 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 » ; **Sprint 63 = démarrage axe B — banc d'essai de pipelines composées mono-document**)
|
| 285 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 286 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 287 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,489 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Banc d'essai de pipelines composées — Sprint 63 (axe B).
|
| 2 |
+
|
| 3 |
+
Sprint 63 — Étape 4 / axe B du plan d'évolution 2026 : démarrage du
|
| 4 |
+
banc d'essai de pipelines.
|
| 5 |
+
|
| 6 |
+
Philosophie
|
| 7 |
+
-----------
|
| 8 |
+
Picarones est un **banc d'essai**, pas un atelier de production.
|
| 9 |
+
Cette infrastructure permet d'**évaluer des pipelines composées de
|
| 10 |
+
modules tiers** que l'utilisateur amène — par exemple :
|
| 11 |
+
|
| 12 |
+
- ``[OCR(image→texte)] → [reconstructeur ALTO tiers(texte→ALTO)]``
|
| 13 |
+
- ``[VLM(image→ALTO)] → [post-processing tiers(ALTO→ALTO)]``
|
| 14 |
+
- ``[OCR(image→texte)] → [LLM correcteur(texte→texte)]``
|
| 15 |
+
|
| 16 |
+
Picarones **ne fournit aucun module métier** (pas de
|
| 17 |
+
reconstructeur ALTO, pas de correcteur, pas de re-segmenteur).
|
| 18 |
+
L'utilisateur branche ses propres ``BaseModule`` (Sprint 33), le
|
| 19 |
+
runner orchestre l'exécution séquentielle, valide les types aux
|
| 20 |
+
jonctions et **évalue automatiquement** chaque artefact produit
|
| 21 |
+
contre la GT du même niveau (Sprint 32) en sélectionnant les
|
| 22 |
+
métriques pertinentes du registre typé (Sprint 34).
|
| 23 |
+
|
| 24 |
+
Périmètre Sprint 63
|
| 25 |
+
-------------------
|
| 26 |
+
Inclus :
|
| 27 |
+
|
| 28 |
+
- Spécification déclarative d'une pipeline séquentielle.
|
| 29 |
+
- Exécution sur un seul document avec passage typé d'artefacts.
|
| 30 |
+
- Validation des types aux jonctions inter-modules.
|
| 31 |
+
- Évaluation automatique aux jonctions GT-vs-sortie pour chaque
|
| 32 |
+
niveau de GT disponible sur le document.
|
| 33 |
+
- Mesure du temps par étape.
|
| 34 |
+
- Capture gracieuse des erreurs (un module qui lève n'arrête pas
|
| 35 |
+
les étapes suivantes — leur entrée manquante est rapportée
|
| 36 |
+
comme erreur explicite).
|
| 37 |
+
|
| 38 |
+
Reporté à des sprints dédiés :
|
| 39 |
+
|
| 40 |
+
- DAG branchant non séquentiel (1 → {2, 3} → 4) — Sprint 64+.
|
| 41 |
+
- Orchestration corpus-wide + agrégation par pipeline — Sprint 65+.
|
| 42 |
+
- Vue HTML dédiée aux pipelines composées — Sprint 66+.
|
| 43 |
+
- Cache d'artefacts intermédiaires — non prévu.
|
| 44 |
+
- Parallélisation inter-étapes — non prévue (les modules
|
| 45 |
+
``execution_mode`` sont déjà respectés par le runner historique
|
| 46 |
+
pour le bench OCR mono-étage).
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
from __future__ import annotations
|
| 50 |
+
|
| 51 |
+
import logging
|
| 52 |
+
import time
|
| 53 |
+
from dataclasses import dataclass, field
|
| 54 |
+
from typing import Any, Optional
|
| 55 |
+
|
| 56 |
+
from picarones.core.corpus import Document, GTLevel
|
| 57 |
+
from picarones.core.metric_registry import compute_at_junction
|
| 58 |
+
from picarones.core.modules import ArtifactType, BaseModule
|
| 59 |
+
|
| 60 |
+
# Eager-load des modules qui enregistrent des métriques dans le
|
| 61 |
+
# registre typé (Sprint 34) — sans ces imports, ``compute_at_junction``
|
| 62 |
+
# trouverait un registre vide et ne calculerait rien aux jonctions.
|
| 63 |
+
# Sprint 34 : cer / wer / mer / wil + stub TEXT→ALTO
|
| 64 |
+
import picarones.core.builtin_metrics # noqa: F401
|
| 65 |
+
# Sprints 55-60 : métriques philologiques.
|
| 66 |
+
import picarones.core.unicode_blocks # noqa: F401
|
| 67 |
+
import picarones.core.abbreviations # noqa: F401
|
| 68 |
+
import picarones.core.mufi # noqa: F401
|
| 69 |
+
import picarones.core.early_modern_typography # noqa: F401
|
| 70 |
+
import picarones.core.modern_archives # noqa: F401
|
| 71 |
+
import picarones.core.roman_numerals # noqa: F401
|
| 72 |
+
# Sprint 53 : reading order F1. Sprints 38, 52 : NER, readability.
|
| 73 |
+
import picarones.core.reading_order # noqa: F401
|
| 74 |
+
import picarones.core.readability # noqa: F401
|
| 75 |
+
import picarones.core.ner # noqa: F401
|
| 76 |
+
|
| 77 |
+
logger = logging.getLogger(__name__)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 81 |
+
# Conversion ArtifactType <-> GTLevel
|
| 82 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _artifact_type_to_gt_level(at: ArtifactType) -> Optional[GTLevel]:
|
| 86 |
+
"""Retourne le ``GTLevel`` correspondant à un ``ArtifactType``.
|
| 87 |
+
|
| 88 |
+
``IMAGE`` n'a pas de correspondance GT (on n'évalue pas une
|
| 89 |
+
image en sortie d'un module — c'est typiquement une entrée).
|
| 90 |
+
"""
|
| 91 |
+
if at == ArtifactType.IMAGE:
|
| 92 |
+
return None
|
| 93 |
+
try:
|
| 94 |
+
return GTLevel(at.value)
|
| 95 |
+
except ValueError:
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 100 |
+
# PipelineStep + PipelineSpec
|
| 101 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@dataclass
|
| 105 |
+
class PipelineStep:
|
| 106 |
+
"""Une étape dans une pipeline composée.
|
| 107 |
+
|
| 108 |
+
L'étape porte un nom lisible (utile pour le rapport et le
|
| 109 |
+
diagnostic) et une instance de ``BaseModule`` fournie par
|
| 110 |
+
l'utilisateur. Les types d'entrée et de sortie ne sont pas
|
| 111 |
+
redéclarés ici : ils sont lus depuis le module lui-même
|
| 112 |
+
(``module.input_types`` / ``module.output_types``).
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
name: str
|
| 116 |
+
module: BaseModule
|
| 117 |
+
|
| 118 |
+
@property
|
| 119 |
+
def input_types(self) -> tuple[ArtifactType, ...]:
|
| 120 |
+
return tuple(self.module.input_types)
|
| 121 |
+
|
| 122 |
+
@property
|
| 123 |
+
def output_types(self) -> tuple[ArtifactType, ...]:
|
| 124 |
+
return tuple(self.module.output_types)
|
| 125 |
+
|
| 126 |
+
def __repr__(self) -> str:
|
| 127 |
+
ins = ",".join(t.value for t in self.input_types) or "·"
|
| 128 |
+
outs = ",".join(t.value for t in self.output_types) or "·"
|
| 129 |
+
return f"PipelineStep({self.name}: {ins} → {outs})"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@dataclass
|
| 133 |
+
class PipelineSpec:
|
| 134 |
+
"""DAG séquentiel de ``PipelineStep``.
|
| 135 |
+
|
| 136 |
+
Sprint 63 — séquentiel uniquement : l'étape ``i+1`` consomme
|
| 137 |
+
les artefacts produits par l'étape ``i`` (et tous les artefacts
|
| 138 |
+
initiaux fournis au runner, par exemple l'image source).
|
| 139 |
+
|
| 140 |
+
Le DAG branchant arrive dans un sprint dédié.
|
| 141 |
+
"""
|
| 142 |
+
|
| 143 |
+
name: str
|
| 144 |
+
steps: list[PipelineStep] = field(default_factory=list)
|
| 145 |
+
|
| 146 |
+
def validate(self, initial_inputs: tuple[ArtifactType, ...]) -> list[str]:
|
| 147 |
+
"""Vérifie que les types s'enchaînent et retourne la liste
|
| 148 |
+
des problèmes détectés (vide si la pipeline est valide).
|
| 149 |
+
|
| 150 |
+
Une pipeline est valide si, pour chaque étape, tous les
|
| 151 |
+
``input_types`` sont disponibles : soit dans les
|
| 152 |
+
``initial_inputs`` (typiquement ``IMAGE``), soit produits
|
| 153 |
+
par une étape antérieure.
|
| 154 |
+
"""
|
| 155 |
+
problems: list[str] = []
|
| 156 |
+
if not self.steps:
|
| 157 |
+
problems.append("pipeline vide : au moins une étape est requise")
|
| 158 |
+
return problems
|
| 159 |
+
available: set[ArtifactType] = set(initial_inputs)
|
| 160 |
+
for i, step in enumerate(self.steps):
|
| 161 |
+
missing = [t for t in step.input_types if t not in available]
|
| 162 |
+
if missing:
|
| 163 |
+
miss_str = ",".join(t.value for t in missing)
|
| 164 |
+
problems.append(
|
| 165 |
+
f"étape {i} ({step.name}) demande {miss_str} "
|
| 166 |
+
f"qui n'est ni dans les entrées initiales "
|
| 167 |
+
f"ni produit par une étape antérieure"
|
| 168 |
+
)
|
| 169 |
+
available.update(step.output_types)
|
| 170 |
+
return problems
|
| 171 |
+
|
| 172 |
+
def is_valid(self, initial_inputs: tuple[ArtifactType, ...]) -> bool:
|
| 173 |
+
return not self.validate(initial_inputs)
|
| 174 |
+
|
| 175 |
+
def __repr__(self) -> str:
|
| 176 |
+
chain = " → ".join(str(s) for s in self.steps)
|
| 177 |
+
return f"PipelineSpec({self.name}: {chain})"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 181 |
+
# StepResult + PipelineResult
|
| 182 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@dataclass
|
| 186 |
+
class StepResult:
|
| 187 |
+
"""Résultat de l'exécution d'une étape sur un document.
|
| 188 |
+
|
| 189 |
+
Champs
|
| 190 |
+
------
|
| 191 |
+
step_name:
|
| 192 |
+
Nom de l'étape (cf. ``PipelineStep.name``).
|
| 193 |
+
duration_seconds:
|
| 194 |
+
Temps d'exécution de ``module.process`` mesuré en wall-clock.
|
| 195 |
+
output_types:
|
| 196 |
+
Types effectivement présents dans la sortie (peut être un
|
| 197 |
+
sous-ensemble de ``module.output_types`` si le module a
|
| 198 |
+
omis un type — cas reporté ici comme info pour diagnostic).
|
| 199 |
+
junction_metrics:
|
| 200 |
+
Pour chaque type produit qui correspond à un ``GTLevel``
|
| 201 |
+
dont le document porte une GT : dictionnaire ``{type: dict
|
| 202 |
+
métriques}`` retourné par ``compute_at_junction``.
|
| 203 |
+
error:
|
| 204 |
+
``None`` si l'étape s'est bien déroulée ; sinon message
|
| 205 |
+
d'erreur (le module a levé, l'entrée est manquante, ou la
|
| 206 |
+
validation des types a échoué).
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
step_name: str
|
| 210 |
+
duration_seconds: float
|
| 211 |
+
output_types: tuple[ArtifactType, ...]
|
| 212 |
+
junction_metrics: dict[str, dict[str, Any]] = field(default_factory=dict)
|
| 213 |
+
"""Map ``{artifact_type_value: {metric_name: value}}``.
|
| 214 |
+
|
| 215 |
+
La clé est la valeur string du ``ArtifactType`` (ex. ``"text"``,
|
| 216 |
+
``"alto"``) et non l'enum lui-même, pour faciliter la
|
| 217 |
+
sérialisation JSON.
|
| 218 |
+
"""
|
| 219 |
+
error: Optional[str] = None
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@dataclass
|
| 223 |
+
class PipelineResult:
|
| 224 |
+
"""Résultat complet d'une exécution de pipeline sur un document.
|
| 225 |
+
|
| 226 |
+
On capture la durée totale, la durée par étape et les
|
| 227 |
+
métriques aux jonctions pour chaque artefact produit qui a une
|
| 228 |
+
GT correspondante.
|
| 229 |
+
"""
|
| 230 |
+
|
| 231 |
+
pipeline_name: str
|
| 232 |
+
doc_id: str
|
| 233 |
+
steps: list[StepResult] = field(default_factory=list)
|
| 234 |
+
total_duration_seconds: float = 0.0
|
| 235 |
+
error: Optional[str] = None
|
| 236 |
+
"""Erreur fatale au niveau pipeline (ex. validation des types
|
| 237 |
+
en amont avant la première étape). ``None`` n'implique pas
|
| 238 |
+
qu'aucune étape n'a échoué — voir ``StepResult.error`` pour le
|
| 239 |
+
détail par étape."""
|
| 240 |
+
|
| 241 |
+
@property
|
| 242 |
+
def succeeded(self) -> bool:
|
| 243 |
+
"""Vrai si la pipeline s'est exécutée jusqu'au bout sans
|
| 244 |
+
qu'aucune étape ne lève d'erreur."""
|
| 245 |
+
if self.error is not None:
|
| 246 |
+
return False
|
| 247 |
+
return all(s.error is None for s in self.steps)
|
| 248 |
+
|
| 249 |
+
@property
|
| 250 |
+
def failing_steps(self) -> list[str]:
|
| 251 |
+
"""Noms des étapes ayant levé une erreur."""
|
| 252 |
+
return [s.step_name for s in self.steps if s.error is not None]
|
| 253 |
+
|
| 254 |
+
def junction_metrics_for(
|
| 255 |
+
self, artifact_type: ArtifactType,
|
| 256 |
+
) -> Optional[dict[str, Any]]:
|
| 257 |
+
"""Retourne les métriques de la **dernière** étape qui a
|
| 258 |
+
produit ``artifact_type``, ou ``None`` si aucune étape ne
|
| 259 |
+
l'a produit avec succès.
|
| 260 |
+
|
| 261 |
+
Utile pour comparer plusieurs pipelines qui produisent in
|
| 262 |
+
fine le même type (ex. deux DAG aboutissant à du texte
|
| 263 |
+
corrigé).
|
| 264 |
+
"""
|
| 265 |
+
for step in reversed(self.steps):
|
| 266 |
+
if step.error is not None:
|
| 267 |
+
continue
|
| 268 |
+
metrics = step.junction_metrics.get(artifact_type.value)
|
| 269 |
+
if metrics is not None:
|
| 270 |
+
return metrics
|
| 271 |
+
return None
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 275 |
+
# Exécuteur
|
| 276 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class PipelineRunner:
|
| 280 |
+
"""Exécute une ``PipelineSpec`` sur un document.
|
| 281 |
+
|
| 282 |
+
Sprint 63 — un seul document à la fois. L'orchestration
|
| 283 |
+
corpus-wide et l'agrégation par pipeline sont reportées à un
|
| 284 |
+
sprint dédié.
|
| 285 |
+
|
| 286 |
+
Usage typique
|
| 287 |
+
-------------
|
| 288 |
+
|
| 289 |
+
>>> spec = PipelineSpec(
|
| 290 |
+
... name="ocr_then_rewrite",
|
| 291 |
+
... steps=[
|
| 292 |
+
... PipelineStep("ocr", my_ocr_module),
|
| 293 |
+
... PipelineStep("rewrite", my_llm_rewriter),
|
| 294 |
+
... ],
|
| 295 |
+
... )
|
| 296 |
+
>>> runner = PipelineRunner()
|
| 297 |
+
>>> result = runner.run(spec, document, {ArtifactType.IMAGE: "/path/img.png"})
|
| 298 |
+
>>> result.succeeded
|
| 299 |
+
True
|
| 300 |
+
>>> result.junction_metrics_for(ArtifactType.TEXT)
|
| 301 |
+
{'cer': 0.05, 'wer': 0.12, ...}
|
| 302 |
+
"""
|
| 303 |
+
|
| 304 |
+
@staticmethod
|
| 305 |
+
def run(
|
| 306 |
+
spec: PipelineSpec,
|
| 307 |
+
document: Document,
|
| 308 |
+
initial_inputs: dict[ArtifactType, Any],
|
| 309 |
+
) -> PipelineResult:
|
| 310 |
+
"""Exécute ``spec`` sur ``document`` à partir de
|
| 311 |
+
``initial_inputs``.
|
| 312 |
+
|
| 313 |
+
Parameters
|
| 314 |
+
----------
|
| 315 |
+
spec:
|
| 316 |
+
Spécification de la pipeline.
|
| 317 |
+
document:
|
| 318 |
+
Document du corpus, porteur de zéro ou plusieurs niveaux
|
| 319 |
+
de GT (Sprint 32).
|
| 320 |
+
initial_inputs:
|
| 321 |
+
Artefacts initiaux par type — typiquement
|
| 322 |
+
``{ArtifactType.IMAGE: "/path/img.png"}`` pour une
|
| 323 |
+
pipeline qui démarre par un OCR.
|
| 324 |
+
|
| 325 |
+
Returns
|
| 326 |
+
-------
|
| 327 |
+
PipelineResult
|
| 328 |
+
Résultat complet : durée totale, résultat par étape,
|
| 329 |
+
métriques aux jonctions évaluées contre la GT.
|
| 330 |
+
"""
|
| 331 |
+
result = PipelineResult(
|
| 332 |
+
pipeline_name=spec.name, doc_id=document.doc_id,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# Validation amont : si la pipeline est statiquement
|
| 336 |
+
# invalide, on n'exécute aucune étape.
|
| 337 |
+
problems = spec.validate(tuple(initial_inputs.keys()))
|
| 338 |
+
if problems:
|
| 339 |
+
result.error = " ; ".join(problems)
|
| 340 |
+
return result
|
| 341 |
+
|
| 342 |
+
# Bag d'artefacts disponibles, mis à jour à chaque étape.
|
| 343 |
+
available: dict[ArtifactType, Any] = dict(initial_inputs)
|
| 344 |
+
|
| 345 |
+
pipeline_t0 = time.monotonic()
|
| 346 |
+
for step in spec.steps:
|
| 347 |
+
step_result = PipelineRunner._run_step(
|
| 348 |
+
step, available, document,
|
| 349 |
+
)
|
| 350 |
+
result.steps.append(step_result)
|
| 351 |
+
# Si l'étape a échoué, les étapes suivantes risquent
|
| 352 |
+
# de manquer leur entrée. On continue quand même pour
|
| 353 |
+
# capturer toutes les erreurs possibles ; chaque étape
|
| 354 |
+
# vérifie ses propres entrées.
|
| 355 |
+
for at in step_result.output_types:
|
| 356 |
+
# Récupère le dernier artefact produit pour ce type
|
| 357 |
+
# depuis ``available`` (mis à jour dans _run_step).
|
| 358 |
+
pass # available déjà mis à jour
|
| 359 |
+
result.total_duration_seconds = time.monotonic() - pipeline_t0
|
| 360 |
+
return result
|
| 361 |
+
|
| 362 |
+
@staticmethod
|
| 363 |
+
def _run_step(
|
| 364 |
+
step: PipelineStep,
|
| 365 |
+
available: dict[ArtifactType, Any],
|
| 366 |
+
document: Document,
|
| 367 |
+
) -> StepResult:
|
| 368 |
+
# Vérification des entrées disponibles
|
| 369 |
+
missing = [t for t in step.input_types if t not in available]
|
| 370 |
+
if missing:
|
| 371 |
+
miss_str = ",".join(t.value for t in missing)
|
| 372 |
+
return StepResult(
|
| 373 |
+
step_name=step.name,
|
| 374 |
+
duration_seconds=0.0,
|
| 375 |
+
output_types=(),
|
| 376 |
+
error=f"entrée manquante : {miss_str}",
|
| 377 |
+
)
|
| 378 |
+
# Construit le sous-dict d'entrées attendues par le module.
|
| 379 |
+
inputs_for_module = {
|
| 380 |
+
t: available[t] for t in step.input_types
|
| 381 |
+
}
|
| 382 |
+
# Exécution chronométrée
|
| 383 |
+
t0 = time.monotonic()
|
| 384 |
+
try:
|
| 385 |
+
outputs = step.module.process(inputs_for_module)
|
| 386 |
+
except Exception as exc: # noqa: BLE001
|
| 387 |
+
duration = time.monotonic() - t0
|
| 388 |
+
logger.warning(
|
| 389 |
+
"[pipeline_runner] étape '%s' a levé : %s",
|
| 390 |
+
step.name, exc,
|
| 391 |
+
)
|
| 392 |
+
return StepResult(
|
| 393 |
+
step_name=step.name,
|
| 394 |
+
duration_seconds=duration,
|
| 395 |
+
output_types=(),
|
| 396 |
+
error=f"{type(exc).__name__}: {exc}",
|
| 397 |
+
)
|
| 398 |
+
duration = time.monotonic() - t0
|
| 399 |
+
|
| 400 |
+
# Validation des sorties : le module est censé déclarer ses
|
| 401 |
+
# output_types, on vérifie qu'il les a tous produits. Si
|
| 402 |
+
# ce n'est pas le cas, on remonte une erreur explicite mais
|
| 403 |
+
# on conserve les sorties effectivement présentes (utile
|
| 404 |
+
# pour le diagnostic).
|
| 405 |
+
if not isinstance(outputs, dict):
|
| 406 |
+
return StepResult(
|
| 407 |
+
step_name=step.name,
|
| 408 |
+
duration_seconds=duration,
|
| 409 |
+
output_types=(),
|
| 410 |
+
error=(
|
| 411 |
+
f"le module a retourné {type(outputs).__name__}, "
|
| 412 |
+
f"un dict[ArtifactType, Any] est attendu"
|
| 413 |
+
),
|
| 414 |
+
)
|
| 415 |
+
produced = tuple(t for t in step.output_types if t in outputs)
|
| 416 |
+
missing_outputs = [t for t in step.output_types if t not in outputs]
|
| 417 |
+
error: Optional[str] = None
|
| 418 |
+
if missing_outputs:
|
| 419 |
+
miss_str = ",".join(t.value for t in missing_outputs)
|
| 420 |
+
error = f"sortie manquante : {miss_str}"
|
| 421 |
+
|
| 422 |
+
# Mise à jour du bag d'artefacts disponibles
|
| 423 |
+
for t in produced:
|
| 424 |
+
available[t] = outputs[t]
|
| 425 |
+
|
| 426 |
+
# Évaluation aux jonctions : pour chaque type produit, si
|
| 427 |
+
# la GT du même niveau existe, on calcule les métriques.
|
| 428 |
+
junction_metrics: dict[str, dict[str, Any]] = {}
|
| 429 |
+
for at in produced:
|
| 430 |
+
gt_level = _artifact_type_to_gt_level(at)
|
| 431 |
+
if gt_level is None:
|
| 432 |
+
continue
|
| 433 |
+
gt_payload = document.get_gt(gt_level)
|
| 434 |
+
if gt_payload is None:
|
| 435 |
+
continue
|
| 436 |
+
try:
|
| 437 |
+
metrics = compute_at_junction(
|
| 438 |
+
_gt_payload_to_value(gt_payload),
|
| 439 |
+
outputs[at],
|
| 440 |
+
(at, at),
|
| 441 |
+
)
|
| 442 |
+
except Exception as exc: # noqa: BLE001
|
| 443 |
+
logger.warning(
|
| 444 |
+
"[pipeline_runner] évaluation à la jonction %s "
|
| 445 |
+
"a levé : %s",
|
| 446 |
+
at.value, exc,
|
| 447 |
+
)
|
| 448 |
+
continue
|
| 449 |
+
if metrics:
|
| 450 |
+
junction_metrics[at.value] = metrics
|
| 451 |
+
|
| 452 |
+
return StepResult(
|
| 453 |
+
step_name=step.name,
|
| 454 |
+
duration_seconds=duration,
|
| 455 |
+
output_types=produced,
|
| 456 |
+
junction_metrics=junction_metrics,
|
| 457 |
+
error=error,
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def _gt_payload_to_value(payload: Any) -> Any:
|
| 462 |
+
"""Extrait la valeur exploitable d'un ``GTPayload`` typé.
|
| 463 |
+
|
| 464 |
+
Pour ``TextGT`` on veut juste la chaîne ; pour les autres
|
| 465 |
+
payloads on retourne le payload entier (la métrique sait quoi
|
| 466 |
+
en faire selon sa signature de types).
|
| 467 |
+
"""
|
| 468 |
+
# Import paresseux pour éviter une dépendance cyclique
|
| 469 |
+
from picarones.core.corpus import (
|
| 470 |
+
AltoGT, EntitiesGT, PageGT, ReadingOrderGT, TextGT,
|
| 471 |
+
)
|
| 472 |
+
if isinstance(payload, TextGT):
|
| 473 |
+
return payload.text
|
| 474 |
+
if isinstance(payload, EntitiesGT):
|
| 475 |
+
return payload.entities
|
| 476 |
+
if isinstance(payload, ReadingOrderGT):
|
| 477 |
+
return payload.region_order
|
| 478 |
+
if isinstance(payload, (AltoGT, PageGT)):
|
| 479 |
+
return payload
|
| 480 |
+
return payload
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
__all__ = [
|
| 484 |
+
"PipelineRunner",
|
| 485 |
+
"PipelineResult",
|
| 486 |
+
"PipelineSpec",
|
| 487 |
+
"PipelineStep",
|
| 488 |
+
"StepResult",
|
| 489 |
+
]
|
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 63 — banc d'essai de pipelines composées (axe B).
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``PipelineSpec.validate`` : pipeline vide, types qui s'enchaînent,
|
| 6 |
+
manque d'entrée à une étape.
|
| 7 |
+
2. ``PipelineRunner.run`` :
|
| 8 |
+
- 1 étape OCR mock + GT TEXT → métriques calculées à la jonction
|
| 9 |
+
- 2 étapes OCR + rewriter LLM mock → 2 jonctions évaluées
|
| 10 |
+
- Module qui lève → propagation gracieuse, étapes suivantes
|
| 11 |
+
reçoivent une erreur explicite d'entrée manquante
|
| 12 |
+
- Sortie déclarée mais non produite → erreur explicite
|
| 13 |
+
- Aucune GT au type produit → pas de métriques (pas d'erreur)
|
| 14 |
+
- Mesure du temps par étape > 0
|
| 15 |
+
3. Cas d'usage réaliste : OCR fautif + rewriter qui corrige → la
|
| 16 |
+
métrique CER baisse à la jonction post-rewrite.
|
| 17 |
+
4. ``PipelineResult.junction_metrics_for`` retourne les métriques
|
| 18 |
+
de la dernière étape ayant produit le type, ignorant les étapes
|
| 19 |
+
qui ont échoué.
|
| 20 |
+
5. **Test philosophie** : Picarones ne fournit pas de modules
|
| 21 |
+
métier — tous les modules utilisés ici sont des **mocks définis
|
| 22 |
+
dans le test**, pas dans le code de production.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
from typing import Any
|
| 28 |
+
|
| 29 |
+
from picarones.core.corpus import Document, GTLevel, TextGT
|
| 30 |
+
from picarones.core.modules import ArtifactType, BaseModule
|
| 31 |
+
from picarones.core.pipeline_runner import (
|
| 32 |
+
PipelineResult,
|
| 33 |
+
PipelineRunner,
|
| 34 |
+
PipelineSpec,
|
| 35 |
+
PipelineStep,
|
| 36 |
+
StepResult,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
+
# Mocks — uniquement à but de test, jamais en production
|
| 42 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class MockOCR(BaseModule):
|
| 46 |
+
"""Mock d'un OCR : produit un texte fixe à partir d'une image."""
|
| 47 |
+
|
| 48 |
+
input_types = (ArtifactType.IMAGE,)
|
| 49 |
+
output_types = (ArtifactType.TEXT,)
|
| 50 |
+
execution_mode: Any = "io"
|
| 51 |
+
|
| 52 |
+
def __init__(self, fixed_output: str) -> None:
|
| 53 |
+
self._out = fixed_output
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def name(self) -> str:
|
| 57 |
+
return "mock-ocr"
|
| 58 |
+
|
| 59 |
+
def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
|
| 60 |
+
return {ArtifactType.TEXT: self._out}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class MockTextRewriter(BaseModule):
|
| 64 |
+
"""Mock d'un correcteur LLM TEXT→TEXT."""
|
| 65 |
+
|
| 66 |
+
input_types = (ArtifactType.TEXT,)
|
| 67 |
+
output_types = (ArtifactType.TEXT,)
|
| 68 |
+
execution_mode: Any = "cpu"
|
| 69 |
+
|
| 70 |
+
def __init__(self, transform) -> None:
|
| 71 |
+
self._transform = transform
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def name(self) -> str:
|
| 75 |
+
return "mock-rewriter"
|
| 76 |
+
|
| 77 |
+
def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
|
| 78 |
+
return {ArtifactType.TEXT: self._transform(inputs[ArtifactType.TEXT])}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class MockCrasher(BaseModule):
|
| 82 |
+
"""Mock d'un module qui lève à chaque appel."""
|
| 83 |
+
|
| 84 |
+
input_types = (ArtifactType.TEXT,)
|
| 85 |
+
output_types = (ArtifactType.TEXT,)
|
| 86 |
+
execution_mode: Any = "cpu"
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def name(self) -> str:
|
| 90 |
+
return "mock-crasher"
|
| 91 |
+
|
| 92 |
+
def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
|
| 93 |
+
raise RuntimeError("module en panne")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class MockSilentDropper(BaseModule):
|
| 97 |
+
"""Mock d'un module qui déclare produire TEXT mais ne le produit pas."""
|
| 98 |
+
|
| 99 |
+
input_types = (ArtifactType.TEXT,)
|
| 100 |
+
output_types = (ArtifactType.TEXT,)
|
| 101 |
+
execution_mode: Any = "cpu"
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def name(self) -> str:
|
| 105 |
+
return "mock-silent-dropper"
|
| 106 |
+
|
| 107 |
+
def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
|
| 108 |
+
return {}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _make_doc(
|
| 112 |
+
text: str = "hello world", with_gt: bool = True,
|
| 113 |
+
) -> Document:
|
| 114 |
+
gts: dict[GTLevel, Any] = {}
|
| 115 |
+
if with_gt:
|
| 116 |
+
gts[GTLevel.TEXT] = TextGT(text=text)
|
| 117 |
+
return Document(
|
| 118 |
+
image_path="/tmp/x.png",
|
| 119 |
+
ground_truth=text if with_gt else "",
|
| 120 |
+
doc_id="d1",
|
| 121 |
+
ground_truths=gts,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 126 |
+
# 1. PipelineSpec.validate
|
| 127 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class TestSpecValidate:
|
| 131 |
+
def test_empty_pipeline_invalid(self) -> None:
|
| 132 |
+
spec = PipelineSpec(name="empty")
|
| 133 |
+
problems = spec.validate(initial_inputs=(ArtifactType.IMAGE,))
|
| 134 |
+
assert problems
|
| 135 |
+
assert "vide" in problems[0]
|
| 136 |
+
|
| 137 |
+
def test_single_step_with_image_input_valid(self) -> None:
|
| 138 |
+
spec = PipelineSpec(
|
| 139 |
+
name="ocr",
|
| 140 |
+
steps=[PipelineStep("ocr", MockOCR("x"))],
|
| 141 |
+
)
|
| 142 |
+
assert spec.is_valid((ArtifactType.IMAGE,))
|
| 143 |
+
|
| 144 |
+
def test_chained_steps_valid(self) -> None:
|
| 145 |
+
spec = PipelineSpec(
|
| 146 |
+
name="ocr_then_rewrite",
|
| 147 |
+
steps=[
|
| 148 |
+
PipelineStep("ocr", MockOCR("x")),
|
| 149 |
+
PipelineStep("rewrite", MockTextRewriter(lambda t: t)),
|
| 150 |
+
],
|
| 151 |
+
)
|
| 152 |
+
assert spec.is_valid((ArtifactType.IMAGE,))
|
| 153 |
+
|
| 154 |
+
def test_missing_input_invalid(self) -> None:
|
| 155 |
+
# Rewriter demande TEXT mais aucun OCR n'a été placé avant
|
| 156 |
+
spec = PipelineSpec(
|
| 157 |
+
name="rewrite_only",
|
| 158 |
+
steps=[PipelineStep("rewrite", MockTextRewriter(lambda t: t))],
|
| 159 |
+
)
|
| 160 |
+
problems = spec.validate(initial_inputs=(ArtifactType.IMAGE,))
|
| 161 |
+
assert problems
|
| 162 |
+
assert "rewrite" in problems[0]
|
| 163 |
+
assert "text" in problems[0]
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 167 |
+
# 2. PipelineRunner.run — chemins nominaux
|
| 168 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class TestRunSingleStep:
|
| 172 |
+
def test_one_step_with_text_gt(self) -> None:
|
| 173 |
+
doc = _make_doc("hello world")
|
| 174 |
+
spec = PipelineSpec(
|
| 175 |
+
name="ocr",
|
| 176 |
+
steps=[PipelineStep("ocr", MockOCR("hello world"))],
|
| 177 |
+
)
|
| 178 |
+
result = PipelineRunner.run(
|
| 179 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 180 |
+
)
|
| 181 |
+
assert result.succeeded
|
| 182 |
+
assert len(result.steps) == 1
|
| 183 |
+
step = result.steps[0]
|
| 184 |
+
assert step.error is None
|
| 185 |
+
assert step.duration_seconds >= 0.0
|
| 186 |
+
# Métrique CER à 0 (hyp == GT)
|
| 187 |
+
assert step.junction_metrics["text"]["cer"] == 0.0
|
| 188 |
+
|
| 189 |
+
def test_one_step_imperfect_ocr(self) -> None:
|
| 190 |
+
doc = _make_doc("hello world")
|
| 191 |
+
spec = PipelineSpec(
|
| 192 |
+
name="ocr",
|
| 193 |
+
steps=[PipelineStep("ocr", MockOCR("hellp wrld"))],
|
| 194 |
+
)
|
| 195 |
+
result = PipelineRunner.run(
|
| 196 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 197 |
+
)
|
| 198 |
+
cer = result.steps[0].junction_metrics["text"]["cer"]
|
| 199 |
+
assert 0.0 < cer < 1.0
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class TestRunChained:
|
| 203 |
+
def test_two_steps_evaluation_at_each_junction(self) -> None:
|
| 204 |
+
doc = _make_doc("hello world")
|
| 205 |
+
# OCR fautif + rewriter qui corrige
|
| 206 |
+
spec = PipelineSpec(
|
| 207 |
+
name="ocr_then_rewrite",
|
| 208 |
+
steps=[
|
| 209 |
+
PipelineStep("ocr", MockOCR("hello wrold")),
|
| 210 |
+
PipelineStep(
|
| 211 |
+
"rewrite",
|
| 212 |
+
MockTextRewriter(lambda t: t.replace("wrold", "world")),
|
| 213 |
+
),
|
| 214 |
+
],
|
| 215 |
+
)
|
| 216 |
+
result = PipelineRunner.run(
|
| 217 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 218 |
+
)
|
| 219 |
+
assert result.succeeded
|
| 220 |
+
assert len(result.steps) == 2
|
| 221 |
+
cer_after_ocr = result.steps[0].junction_metrics["text"]["cer"]
|
| 222 |
+
cer_after_rewrite = result.steps[1].junction_metrics["text"]["cer"]
|
| 223 |
+
# Le CER baisse après le rewriter
|
| 224 |
+
assert cer_after_rewrite < cer_after_ocr
|
| 225 |
+
assert cer_after_rewrite == 0.0
|
| 226 |
+
|
| 227 |
+
def test_junction_metrics_for_returns_last(self) -> None:
|
| 228 |
+
doc = _make_doc("hello world")
|
| 229 |
+
spec = PipelineSpec(
|
| 230 |
+
name="ocr_then_rewrite",
|
| 231 |
+
steps=[
|
| 232 |
+
PipelineStep("ocr", MockOCR("hello wrold")),
|
| 233 |
+
PipelineStep(
|
| 234 |
+
"rewrite",
|
| 235 |
+
MockTextRewriter(lambda t: t.replace("wrold", "world")),
|
| 236 |
+
),
|
| 237 |
+
],
|
| 238 |
+
)
|
| 239 |
+
result = PipelineRunner.run(
|
| 240 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 241 |
+
)
|
| 242 |
+
final = result.junction_metrics_for(ArtifactType.TEXT)
|
| 243 |
+
assert final is not None
|
| 244 |
+
assert final["cer"] == 0.0
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 248 |
+
# 3. Erreurs gracieuses
|
| 249 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class TestGracefulErrors:
|
| 253 |
+
def test_module_raises_captured(self) -> None:
|
| 254 |
+
doc = _make_doc("hello world")
|
| 255 |
+
spec = PipelineSpec(
|
| 256 |
+
name="crash",
|
| 257 |
+
steps=[
|
| 258 |
+
PipelineStep("ocr", MockOCR("hello world")),
|
| 259 |
+
PipelineStep("crash", MockCrasher()),
|
| 260 |
+
],
|
| 261 |
+
)
|
| 262 |
+
result = PipelineRunner.run(
|
| 263 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 264 |
+
)
|
| 265 |
+
assert not result.succeeded
|
| 266 |
+
assert result.steps[1].error is not None
|
| 267 |
+
assert "RuntimeError" in result.steps[1].error
|
| 268 |
+
assert "panne" in result.steps[1].error
|
| 269 |
+
# L'étape précédente reste OK
|
| 270 |
+
assert result.steps[0].error is None
|
| 271 |
+
assert result.failing_steps == ["crash"]
|
| 272 |
+
|
| 273 |
+
def test_silent_dropper_reported_as_missing_output(self) -> None:
|
| 274 |
+
doc = _make_doc("hello world")
|
| 275 |
+
spec = PipelineSpec(
|
| 276 |
+
name="dropper",
|
| 277 |
+
steps=[
|
| 278 |
+
PipelineStep("ocr", MockOCR("hello world")),
|
| 279 |
+
PipelineStep("drop", MockSilentDropper()),
|
| 280 |
+
],
|
| 281 |
+
)
|
| 282 |
+
result = PipelineRunner.run(
|
| 283 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 284 |
+
)
|
| 285 |
+
# L'étape drop signale une sortie manquante
|
| 286 |
+
assert result.steps[1].error is not None
|
| 287 |
+
assert "sortie manquante" in result.steps[1].error
|
| 288 |
+
|
| 289 |
+
def test_invalid_spec_marked_as_error(self) -> None:
|
| 290 |
+
doc = _make_doc()
|
| 291 |
+
# Pipeline qui demande TEXT mais on ne fournit que IMAGE
|
| 292 |
+
# et aucun OCR ne précède
|
| 293 |
+
spec = PipelineSpec(
|
| 294 |
+
name="bad",
|
| 295 |
+
steps=[PipelineStep("rewrite", MockTextRewriter(lambda t: t))],
|
| 296 |
+
)
|
| 297 |
+
result = PipelineRunner.run(
|
| 298 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 299 |
+
)
|
| 300 |
+
assert result.error is not None
|
| 301 |
+
assert "text" in result.error
|
| 302 |
+
# Aucune étape n'a été exécutée
|
| 303 |
+
assert result.steps == []
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 307 |
+
# 4. Pas de GT → pas de métriques mais pas d'erreur
|
| 308 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
class TestNoGroundTruth:
|
| 312 |
+
def test_no_gt_no_metrics_no_error(self) -> None:
|
| 313 |
+
doc = _make_doc(with_gt=False)
|
| 314 |
+
spec = PipelineSpec(
|
| 315 |
+
name="ocr",
|
| 316 |
+
steps=[PipelineStep("ocr", MockOCR("anything"))],
|
| 317 |
+
)
|
| 318 |
+
result = PipelineRunner.run(
|
| 319 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 320 |
+
)
|
| 321 |
+
# Pas d'erreur — la pipeline a tourné, simplement aucune
|
| 322 |
+
# métrique calculable
|
| 323 |
+
# (Document __post_init__ crée TextGT depuis ground_truth=""
|
| 324 |
+
# donc une GT vide existe ; la métrique CER vaudra alors 1.0
|
| 325 |
+
# ce qui est un autre test ; pour ce test on retire la GT.)
|
| 326 |
+
# On accepte donc soit absence soit présence du dict junction_metrics ;
|
| 327 |
+
# le point clé est que ça ne plante pas.
|
| 328 |
+
assert result.steps[0].error is None
|
| 329 |
+
assert result.succeeded
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 333 |
+
# 5. Temps par étape
|
| 334 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
class TestTiming:
|
| 338 |
+
def test_step_duration_recorded(self) -> None:
|
| 339 |
+
doc = _make_doc()
|
| 340 |
+
spec = PipelineSpec(
|
| 341 |
+
name="ocr",
|
| 342 |
+
steps=[PipelineStep("ocr", MockOCR("hello"))],
|
| 343 |
+
)
|
| 344 |
+
result = PipelineRunner.run(
|
| 345 |
+
spec, doc, {ArtifactType.IMAGE: "/tmp/x.png"},
|
| 346 |
+
)
|
| 347 |
+
assert result.steps[0].duration_seconds >= 0.0
|
| 348 |
+
assert result.total_duration_seconds >= result.steps[0].duration_seconds
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 352 |
+
# 6. Dataclasses (StepResult / PipelineResult)
|
| 353 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
class TestDataclasses:
|
| 357 |
+
def test_step_result_default(self) -> None:
|
| 358 |
+
sr = StepResult(
|
| 359 |
+
step_name="x", duration_seconds=0.1, output_types=(),
|
| 360 |
+
)
|
| 361 |
+
assert sr.junction_metrics == {}
|
| 362 |
+
assert sr.error is None
|
| 363 |
+
|
| 364 |
+
def test_pipeline_result_succeeded_false_on_step_error(self) -> None:
|
| 365 |
+
pr = PipelineResult(
|
| 366 |
+
pipeline_name="p", doc_id="d",
|
| 367 |
+
steps=[
|
| 368 |
+
StepResult(step_name="a", duration_seconds=0.1,
|
| 369 |
+
output_types=(ArtifactType.TEXT,)),
|
| 370 |
+
StepResult(step_name="b", duration_seconds=0.1,
|
| 371 |
+
output_types=(), error="boom"),
|
| 372 |
+
],
|
| 373 |
+
)
|
| 374 |
+
assert not pr.succeeded
|
| 375 |
+
assert pr.failing_steps == ["b"]
|
| 376 |
+
|
| 377 |
+
def test_junction_metrics_for_skips_failed_steps(self) -> None:
|
| 378 |
+
# Étape 1 a échoué, étape 0 a produit TEXT avec une métrique
|
| 379 |
+
pr = PipelineResult(
|
| 380 |
+
pipeline_name="p", doc_id="d",
|
| 381 |
+
steps=[
|
| 382 |
+
StepResult(
|
| 383 |
+
step_name="ocr", duration_seconds=0.1,
|
| 384 |
+
output_types=(ArtifactType.TEXT,),
|
| 385 |
+
junction_metrics={"text": {"cer": 0.1}},
|
| 386 |
+
),
|
| 387 |
+
StepResult(
|
| 388 |
+
step_name="rewrite", duration_seconds=0.1,
|
| 389 |
+
output_types=(), error="boom",
|
| 390 |
+
),
|
| 391 |
+
],
|
| 392 |
+
)
|
| 393 |
+
# On doit retomber sur l'étape OCR (la dernière qui a réussi
|
| 394 |
+
# pour TEXT)
|
| 395 |
+
assert pr.junction_metrics_for(ArtifactType.TEXT) == {"cer": 0.1}
|