Spaces:
Sleeping
feat(migration): Phase 5.C batch 7 — pré-requis + 2 derniers renderers
Browse filesSeptième et dernière vague de Phase 5.C. Migre d'abord les
modules de mesure dont dépendent les renderers
``numerical_sequences`` et ``pipeline``, puis migre ces 2 derniers
renderers vers ``reports_v2/html/renderers/``.
Pré-requis migrés (modules de mesure)
-------------------------------------
| Source legacy | Destination canonique |
|------------------------------------------------|------------------------------------------------|
| ``measurements/roman_numerals.py`` (478) | ``evaluation/metrics/roman_numerals.py`` |
| ``measurements/numerical_sequences.py`` (422) | ``evaluation/metrics/numerical_sequences.py`` |
| ``measurements/pipeline_benchmark.py`` (367) | ``evaluation/pipeline_benchmark.py`` |
| ``measurements/pipeline_comparison.py`` (301) | ``evaluation/pipeline_comparison.py`` |
| ``core/pipeline.py`` (607) | ``evaluation/pipeline.py`` |
Puis les 2 derniers renderers
-----------------------------
| Source legacy | Destination canonique |
|------------------------------------------------|------------------------------------------------------|
| ``report/numerical_sequences_render.py`` (149) | ``reports_v2/html/renderers/numerical_sequences.py`` |
| ``report/pipeline_render.py`` (707) | ``reports_v2/html/renderers/pipeline.py`` |
Total : ~3031 lignes relocalisées. 7 nouveaux shims minimaux.
État final de ``picarones/core/``
---------------------------------
Le répertoire ``picarones/core/`` est désormais **entièrement
constitué de shims** (10 fichiers, tous < 30 lignes). Aucun
module Cercle 1 réel ne subsiste — les abstractions vivent dans
``domain/`` (Pydantic immutable) et ``evaluation/`` (riche en
behavior). ``EXPECTED_CERCLE1`` du test
``test_public_api.py::TestCercle1IsLean`` est désormais un set
vide, documentant explicitement que la Phase 1 du retrait du
legacy est complète au niveau ``core/``.
Adaptations transverses
-----------------------
- Imports internes mis à jour entre modules canoniques.
- ``test_module_coverage.py::TEST_ONLY_BASELINE`` étendu à 4
modules supplémentaires.
- ``test_file_budgets.py`` : 4 entrées legacy retirées,
remplacées par les chemins canoniques.
- ``docs/tutorials/writing-a-pipeline-module.md`` : tous les
imports mis à jour.
Cumul Phase 5.C
---------------
**29 / 29 renderers migrés** (~8263 lignes au total) à travers
les 7 batches. Phase 5.C est terminée.
Acceptance
----------
5019 tests passent, lint vert, architecture vérifiée.
Restantes pour Phase 5
----------------------
- Phase 5.D : 5 vues (``views/*.py``).
- Phase 5.E : ``generator.py``, ``comparison.py``,
``snapshot.py``, ``report_data/``, templates Jinja2.
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- docs/migration/legacy-retirement-plan.md +72 -6
- docs/tutorials/writing-a-pipeline-module.md +6 -6
- picarones/__init__.py +1 -1
- picarones/cli/_pipeline.py +4 -4
- picarones/core/pipeline.py +11 -600
- picarones/evaluation/metrics/numerical_sequences.py +428 -0
- picarones/evaluation/metrics/roman_numerals.py +484 -0
- picarones/evaluation/pipeline.py +622 -0
- picarones/evaluation/pipeline_benchmark.py +373 -0
- picarones/evaluation/pipeline_comparison.py +307 -0
- picarones/measurements/builtin_hooks.py +2 -2
- picarones/measurements/numerical_sequences.py +10 -414
- picarones/measurements/numerical_sequences_hooks.py +1 -1
- picarones/measurements/philological_hooks.py +2 -2
- picarones/measurements/pipeline_benchmark.py +10 -359
- picarones/measurements/pipeline_comparison.py +11 -294
- picarones/measurements/pipeline_spec_loader.py +1 -1
- picarones/measurements/roman_numerals.py +10 -470
- picarones/report/generator.py +1 -1
- picarones/report/numerical_sequences_render.py +11 -142
- picarones/report/pipeline_render.py +11 -700
- picarones/report/views/pipeline.py +1 -1
- picarones/reports_v2/html/renderers/numerical_sequences.py +155 -0
- picarones/reports_v2/html/renderers/pipeline.py +713 -0
- tests/architecture/test_file_budgets.py +14 -4
- tests/architecture/test_module_coverage.py +11 -0
- tests/core/test_public_api.py +20 -19
- tests/core/test_sprint63_pipeline_runner.py +1 -1
- tests/core/test_sprint66_dag_branching.py +1 -1
- tests/integration/test_alto_baseline.py +1 -1
- tests/integration/test_pipeline_ocr_to_alto.py +1 -1
- tests/integration/test_sprint69_user_doc.py +4 -4
- tests/measurements/test_sprint60_roman_numerals.py +1 -1
- tests/measurements/test_sprint64_pipeline_benchmark.py +2 -2
- tests/measurements/test_sprint65_pipeline_comparison.py +2 -2
- tests/measurements/test_sprint85_numerical_sequences.py +1 -1
- tests/report/test_sprint67_pipeline_html.py +2 -2
- tests/report/test_sprint68_pipeline_comparison_html.py +3 -3
- tests/report/test_sprint86_aii5_html.py +1 -1
|
@@ -696,12 +696,11 @@ architecture vérifiée.
|
|
| 696 |
- Batch 4 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
|
| 697 |
- Batch 5 ✅ (cf. ci-dessous) — 5 renderers (148-314 LOC).
|
| 698 |
- Batch 6 ✅ (cf. ci-dessous) — 2 renderers (``levers``, ``philological``).
|
| 699 |
-
- Batch 7
|
| 700 |
-
``
|
| 701 |
-
|
| 702 |
-
``
|
| 703 |
-
``
|
| 704 |
-
``measurements/roman_numerals`` vers ``evaluation/metrics/``.
|
| 705 |
- Phase 5.D : 5 vues (``views/*.py``).
|
| 706 |
- Phase 5.E : ``generator.py``, ``comparison.py``,
|
| 707 |
``snapshot.py``, ``report_data/``, templates Jinja2.
|
|
@@ -886,6 +885,73 @@ Total : ~776 lignes relocalisées.
|
|
| 886 |
**Acceptance batch 6** : 5019 tests passent, lint vert,
|
| 887 |
architecture vérifiée.
|
| 888 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 889 |
### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
|
| 890 |
|
| 891 |
**Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
|
|
|
|
| 696 |
- Batch 4 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
|
| 697 |
- Batch 5 ✅ (cf. ci-dessous) — 5 renderers (148-314 LOC).
|
| 698 |
- Batch 6 ✅ (cf. ci-dessous) — 2 renderers (``levers``, ``philological``).
|
| 699 |
+
- Batch 7 ✅ (cf. ci-dessous) — pré-requis migrés
|
| 700 |
+
(``roman_numerals``, ``numerical_sequences``,
|
| 701 |
+
``pipeline_benchmark``, ``pipeline_comparison``,
|
| 702 |
+
``core/pipeline``) puis 2 renderers
|
| 703 |
+
(``numerical_sequences``, ``pipeline``).
|
|
|
|
| 704 |
- Phase 5.D : 5 vues (``views/*.py``).
|
| 705 |
- Phase 5.E : ``generator.py``, ``comparison.py``,
|
| 706 |
``snapshot.py``, ``report_data/``, templates Jinja2.
|
|
|
|
| 885 |
**Acceptance batch 6** : 5019 tests passent, lint vert,
|
| 886 |
architecture vérifiée.
|
| 887 |
|
| 888 |
+
#### Phase 5.C.batch7 — Lot 7 : pré-requis + 2 derniers renderers (2026-05)
|
| 889 |
+
|
| 890 |
+
Le batch 7 finalise Phase 5.C en migrant **d'abord** les
|
| 891 |
+
modules de mesure dont dépendent les renderers
|
| 892 |
+
``numerical_sequences`` et ``pipeline`` :
|
| 893 |
+
|
| 894 |
+
| Source legacy | Destination canonique |
|
| 895 |
+
|------------------------------------------------|------------------------------------------------|
|
| 896 |
+
| ``measurements/roman_numerals.py`` (478) | ``evaluation/metrics/roman_numerals.py`` |
|
| 897 |
+
| ``measurements/numerical_sequences.py`` (422) | ``evaluation/metrics/numerical_sequences.py`` |
|
| 898 |
+
| ``measurements/pipeline_benchmark.py`` (367) | ``evaluation/pipeline_benchmark.py`` |
|
| 899 |
+
| ``measurements/pipeline_comparison.py`` (301) | ``evaluation/pipeline_comparison.py`` |
|
| 900 |
+
| ``core/pipeline.py`` (607) | ``evaluation/pipeline.py`` |
|
| 901 |
+
|
| 902 |
+
Puis les 2 derniers renderers :
|
| 903 |
+
|
| 904 |
+
| Source legacy | Destination canonique |
|
| 905 |
+
|------------------------------------------------|------------------------------------------------------|
|
| 906 |
+
| ``report/numerical_sequences_render.py`` (149) | ``reports_v2/html/renderers/numerical_sequences.py`` |
|
| 907 |
+
| ``report/pipeline_render.py`` (707) | ``reports_v2/html/renderers/pipeline.py`` |
|
| 908 |
+
|
| 909 |
+
Total : ~3031 lignes relocalisées dans ce batch. 7 nouveaux
|
| 910 |
+
shims minimaux (< 25 lignes) avec ``DeprecationWarning``.
|
| 911 |
+
|
| 912 |
+
État final de ``picarones/core/``
|
| 913 |
+
---------------------------------
|
| 914 |
+
|
| 915 |
+
Le répertoire ``picarones/core/`` est désormais **entièrement
|
| 916 |
+
constitué de shims** (10 fichiers, tous < 30 lignes). Aucun
|
| 917 |
+
module Cercle 1 réel ne subsiste — les abstractions vivent dans
|
| 918 |
+
``domain/`` (Pydantic immutable) et ``evaluation/`` (riche en
|
| 919 |
+
behavior). ``EXPECTED_CERCLE1`` du test
|
| 920 |
+
``test_public_api.py::TestCercle1IsLean`` est désormais un set
|
| 921 |
+
vide, documentant explicitement que la Phase 1 du retrait du
|
| 922 |
+
legacy est complète au niveau ``core/``.
|
| 923 |
+
|
| 924 |
+
Adaptations transverses
|
| 925 |
+
-----------------------
|
| 926 |
+
|
| 927 |
+
- Imports internes mis à jour entre modules canoniques
|
| 928 |
+
(``evaluation/metrics/numerical_sequences.py`` → canonique
|
| 929 |
+
``roman_numerals``, ``evaluation/pipeline_comparison.py`` →
|
| 930 |
+
canonique ``pipeline_benchmark``, etc.).
|
| 931 |
+
- ``test_module_coverage.py::TEST_ONLY_BASELINE`` étendu à
|
| 932 |
+
``"numerical_sequences"``, ``"numerical_sequences_hooks"``,
|
| 933 |
+
``"pipeline_benchmark"``, ``"pipeline_comparison"``.
|
| 934 |
+
- ``test_file_budgets.py`` : 4 entrées legacy retirées,
|
| 935 |
+
remplacées par les chemins canoniques.
|
| 936 |
+
- ``test_public_api.py::EXPECTED_CERCLE1`` : ``pipeline.py``
|
| 937 |
+
retiré (set désormais vide).
|
| 938 |
+
- ``docs/tutorials/writing-a-pipeline-module.md`` : tous les
|
| 939 |
+
imports mis à jour vers les chemins canoniques.
|
| 940 |
+
|
| 941 |
+
**Cumul Phase 5.C** (batches 1-7) : **29 / 29 renderers migrés**
|
| 942 |
+
(~8263 lignes au total). Phase 5.C est terminée.
|
| 943 |
+
|
| 944 |
+
**Acceptance batch 7** : 5019 tests passent, lint vert,
|
| 945 |
+
architecture vérifiée (anti-cycles, file budgets,
|
| 946 |
+
EXPECTED_CERCLE1 vide).
|
| 947 |
+
|
| 948 |
+
Restantes pour Phase 5
|
| 949 |
+
----------------------
|
| 950 |
+
|
| 951 |
+
- Phase 5.D : 5 vues (``views/*.py``).
|
| 952 |
+
- Phase 5.E : ``generator.py``, ``comparison.py``,
|
| 953 |
+
``snapshot.py``, ``report_data/``, templates Jinja2.
|
| 954 |
+
|
| 955 |
### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
|
| 956 |
|
| 957 |
**Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
|
|
@@ -18,7 +18,7 @@
|
|
| 18 |
|
| 19 |
```python
|
| 20 |
from picarones.core.modules import BaseModule, ArtifactType
|
| 21 |
-
from picarones.
|
| 22 |
PipelineRunner, PipelineSpec, PipelineStep,
|
| 23 |
)
|
| 24 |
|
|
@@ -150,7 +150,7 @@ class NERExtractor(BaseModule):
|
|
| 150 |
### 3.a Mono-document (Sprint 63)
|
| 151 |
|
| 152 |
```python
|
| 153 |
-
from picarones.
|
| 154 |
PipelineRunner, PipelineSpec, PipelineStep,
|
| 155 |
)
|
| 156 |
|
|
@@ -178,7 +178,7 @@ que `Document.ground_truths` porte une `TextGT` (ou `AltoGT`,
|
|
| 178 |
### 3.b Corpus complet (Sprint 64)
|
| 179 |
|
| 180 |
```python
|
| 181 |
-
from picarones.
|
| 182 |
|
| 183 |
bench = run_pipeline_benchmark(spec, my_corpus)
|
| 184 |
print(bench.n_pipelines_succeeded, "/", bench.n_docs)
|
|
@@ -203,7 +203,7 @@ bench = run_pipeline_benchmark(spec, corpus, initial_inputs_factory=my_factory)
|
|
| 203 |
### 3.c Comparer N pipelines (Sprint 65)
|
| 204 |
|
| 205 |
```python
|
| 206 |
-
from picarones.
|
| 207 |
|
| 208 |
comparison = compare_pipelines(
|
| 209 |
[spec_baseline, spec_with_correcteur_a, spec_with_correcteur_b],
|
|
@@ -259,7 +259,7 @@ Sans `inputs_from`, `correct_b` aurait reçu la sortie de
|
|
| 259 |
|
| 260 |
```python
|
| 261 |
from pathlib import Path
|
| 262 |
-
from picarones.
|
| 263 |
|
| 264 |
bench = run_pipeline_benchmark(spec, corpus)
|
| 265 |
Path("rapport_pipeline.html").write_text(
|
|
@@ -271,7 +271,7 @@ Path("rapport_pipeline.html").write_text(
|
|
| 271 |
|
| 272 |
```python
|
| 273 |
from picarones.core.modules import ArtifactType
|
| 274 |
-
from picarones.
|
| 275 |
RankingSpec, build_pipeline_comparison_report_html,
|
| 276 |
)
|
| 277 |
|
|
|
|
| 18 |
|
| 19 |
```python
|
| 20 |
from picarones.core.modules import BaseModule, ArtifactType
|
| 21 |
+
from picarones.evaluation.pipeline import (
|
| 22 |
PipelineRunner, PipelineSpec, PipelineStep,
|
| 23 |
)
|
| 24 |
|
|
|
|
| 150 |
### 3.a Mono-document (Sprint 63)
|
| 151 |
|
| 152 |
```python
|
| 153 |
+
from picarones.evaluation.pipeline import (
|
| 154 |
PipelineRunner, PipelineSpec, PipelineStep,
|
| 155 |
)
|
| 156 |
|
|
|
|
| 178 |
### 3.b Corpus complet (Sprint 64)
|
| 179 |
|
| 180 |
```python
|
| 181 |
+
from picarones.evaluation.pipeline_benchmark import run_pipeline_benchmark
|
| 182 |
|
| 183 |
bench = run_pipeline_benchmark(spec, my_corpus)
|
| 184 |
print(bench.n_pipelines_succeeded, "/", bench.n_docs)
|
|
|
|
| 203 |
### 3.c Comparer N pipelines (Sprint 65)
|
| 204 |
|
| 205 |
```python
|
| 206 |
+
from picarones.evaluation.pipeline_comparison import compare_pipelines
|
| 207 |
|
| 208 |
comparison = compare_pipelines(
|
| 209 |
[spec_baseline, spec_with_correcteur_a, spec_with_correcteur_b],
|
|
|
|
| 259 |
|
| 260 |
```python
|
| 261 |
from pathlib import Path
|
| 262 |
+
from picarones.reports_v2.html.renderers.pipeline import build_pipeline_report_html
|
| 263 |
|
| 264 |
bench = run_pipeline_benchmark(spec, corpus)
|
| 265 |
Path("rapport_pipeline.html").write_text(
|
|
|
|
| 271 |
|
| 272 |
```python
|
| 273 |
from picarones.core.modules import ArtifactType
|
| 274 |
+
from picarones.reports_v2.html.renderers.pipeline import (
|
| 275 |
RankingSpec, build_pipeline_comparison_report_html,
|
| 276 |
)
|
| 277 |
|
|
@@ -69,7 +69,7 @@ from picarones.domain.facts import (
|
|
| 69 |
FactImportance,
|
| 70 |
FactType,
|
| 71 |
)
|
| 72 |
-
from picarones.
|
| 73 |
PipelineResult,
|
| 74 |
PipelineRunner,
|
| 75 |
PipelineSpec,
|
|
|
|
| 69 |
FactImportance,
|
| 70 |
FactType,
|
| 71 |
)
|
| 72 |
+
from picarones.evaluation.pipeline import (
|
| 73 |
PipelineResult,
|
| 74 |
PipelineRunner,
|
| 75 |
PipelineSpec,
|
|
@@ -66,7 +66,7 @@ def pipeline_run_cmd(
|
|
| 66 |
import json as _json
|
| 67 |
|
| 68 |
from picarones.evaluation.corpus import load_corpus_from_directory
|
| 69 |
-
from picarones.
|
| 70 |
from picarones.measurements.pipeline_spec_loader import load_pipeline_spec_from_yaml
|
| 71 |
|
| 72 |
spec = load_pipeline_spec_from_yaml(spec_path)
|
|
@@ -114,7 +114,7 @@ def pipeline_run_cmd(
|
|
| 114 |
)
|
| 115 |
click.echo(f"JSON exporté : {output_json}")
|
| 116 |
if output_html is not None:
|
| 117 |
-
from picarones.
|
| 118 |
Path(output_html).write_text(
|
| 119 |
build_pipeline_report_html(bench, lang=lang),
|
| 120 |
encoding="utf-8",
|
|
@@ -163,7 +163,7 @@ def pipeline_compare_cmd(
|
|
| 163 |
"""Compare N pipelines décrites dans SPECS_PATH sur le même corpus."""
|
| 164 |
from picarones.evaluation.corpus import load_corpus_from_directory
|
| 165 |
from picarones.domain.artifacts import ArtifactType
|
| 166 |
-
from picarones.
|
| 167 |
from picarones.measurements.pipeline_spec_loader import (
|
| 168 |
load_comparison_specs_from_yaml,
|
| 169 |
)
|
|
@@ -187,7 +187,7 @@ def pipeline_compare_cmd(
|
|
| 187 |
shown = f"{value:.4f}" if value is not None else "N/A"
|
| 188 |
click.echo(f" {i}. {name}: {shown}")
|
| 189 |
if output_html is not None:
|
| 190 |
-
from picarones.
|
| 191 |
RankingSpec,
|
| 192 |
build_pipeline_comparison_report_html,
|
| 193 |
)
|
|
|
|
| 66 |
import json as _json
|
| 67 |
|
| 68 |
from picarones.evaluation.corpus import load_corpus_from_directory
|
| 69 |
+
from picarones.evaluation.pipeline_benchmark import run_pipeline_benchmark
|
| 70 |
from picarones.measurements.pipeline_spec_loader import load_pipeline_spec_from_yaml
|
| 71 |
|
| 72 |
spec = load_pipeline_spec_from_yaml(spec_path)
|
|
|
|
| 114 |
)
|
| 115 |
click.echo(f"JSON exporté : {output_json}")
|
| 116 |
if output_html is not None:
|
| 117 |
+
from picarones.reports_v2.html.renderers.pipeline import build_pipeline_report_html
|
| 118 |
Path(output_html).write_text(
|
| 119 |
build_pipeline_report_html(bench, lang=lang),
|
| 120 |
encoding="utf-8",
|
|
|
|
| 163 |
"""Compare N pipelines décrites dans SPECS_PATH sur le même corpus."""
|
| 164 |
from picarones.evaluation.corpus import load_corpus_from_directory
|
| 165 |
from picarones.domain.artifacts import ArtifactType
|
| 166 |
+
from picarones.evaluation.pipeline_comparison import compare_pipelines
|
| 167 |
from picarones.measurements.pipeline_spec_loader import (
|
| 168 |
load_comparison_specs_from_yaml,
|
| 169 |
)
|
|
|
|
| 187 |
shown = f"{value:.4f}" if value is not None else "N/A"
|
| 188 |
click.echo(f" {i}. {name}: {shown}")
|
| 189 |
if output_html is not None:
|
| 190 |
+
from picarones.reports_v2.html.renderers.pipeline import (
|
| 191 |
RankingSpec,
|
| 192 |
build_pipeline_comparison_report_html,
|
| 193 |
)
|
|
@@ -1,607 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 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
|
| 52 |
-
import time
|
| 53 |
-
from dataclasses import dataclass, field
|
| 54 |
-
from typing import Any, Optional
|
| 55 |
-
|
| 56 |
-
from picarones.evaluation.corpus import Document, GTLevel
|
| 57 |
-
from picarones.evaluation.metric_registry import compute_at_junction
|
| 58 |
-
from picarones.domain.artifacts import ArtifactType
|
| 59 |
-
from picarones.domain.module_protocol import BaseModule
|
| 60 |
-
|
| 61 |
-
# Sprint A3 (renforce la règle Cercle 1 → Cercle 1 uniquement) — la
|
| 62 |
-
# cérémonie d'eager-load des métriques typées (Sprint 34) qui vivait
|
| 63 |
-
# ici a été déplacée dans ``picarones/measurements/__init__.py``. Tout
|
| 64 |
-
# consommateur de ``compute_at_junction`` (typiquement la classe
|
| 65 |
-
# ``PipelineRunner`` ci-dessous) doit avoir importé
|
| 66 |
-
# ``picarones.measurements`` au moins une fois — c'est le cas dans
|
| 67 |
-
# l'API publique via ``picarones.__init__`` qui déclenche le trigger.
|
| 68 |
-
|
| 69 |
-
logger = logging.getLogger(__name__)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 73 |
-
# Conversion ArtifactType <-> GTLevel
|
| 74 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
#: Map ``ArtifactType`` canonique → ``GTLevel`` legacy. Phase 4-bis :
|
| 78 |
-
#: ``ArtifactType`` a été migré vers ``domain/artifacts.py`` qui
|
| 79 |
-
#: distingue ``RAW_TEXT``/``CORRECTED_TEXT`` (vs ``TEXT`` legacy) et
|
| 80 |
-
#: ``ALTO_XML``/``PAGE_XML`` (vs ``ALTO``/``PAGE`` legacy). Les
|
| 81 |
-
#: valeurs canoniques ne matchent donc plus celles de ``GTLevel``.
|
| 82 |
-
#: Ce mapping explicite fait le pont — sera retiré en 2.0 quand
|
| 83 |
-
#: ``GTLevel`` aura aussi été retiré au profit de la projection
|
| 84 |
-
#: ``ArtifactType → niveau d'évaluation`` du rewrite.
|
| 85 |
-
_ARTIFACT_TO_GT_LEVEL: dict[ArtifactType, GTLevel] = {
|
| 86 |
-
ArtifactType.RAW_TEXT: GTLevel.TEXT,
|
| 87 |
-
ArtifactType.CORRECTED_TEXT: GTLevel.TEXT,
|
| 88 |
-
ArtifactType.ALTO_XML: GTLevel.ALTO,
|
| 89 |
-
ArtifactType.PAGE_XML: GTLevel.PAGE,
|
| 90 |
-
ArtifactType.ENTITIES: GTLevel.ENTITIES,
|
| 91 |
-
ArtifactType.READING_ORDER: GTLevel.READING_ORDER,
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def _artifact_type_to_gt_level(at: ArtifactType) -> Optional[GTLevel]:
|
| 96 |
-
"""Retourne le ``GTLevel`` correspondant à un ``ArtifactType``.
|
| 97 |
-
|
| 98 |
-
``IMAGE`` n'a pas de correspondance GT (on n'évalue pas une
|
| 99 |
-
image en sortie d'un module — c'est typiquement une entrée).
|
| 100 |
-
Les types ``CONFIDENCES``, ``ALIGNMENT``, ``CANONICAL_DOCUMENT``
|
| 101 |
-
n'ont pas non plus de niveau de GT direct dans le legacy.
|
| 102 |
-
"""
|
| 103 |
-
return _ARTIFACT_TO_GT_LEVEL.get(at)
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 107 |
-
# PipelineStep + PipelineSpec
|
| 108 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
@dataclass
|
| 112 |
-
class PipelineStep:
|
| 113 |
-
"""Une étape dans une pipeline composée.
|
| 114 |
-
|
| 115 |
-
L'étape porte un nom lisible (utile pour le rapport et le
|
| 116 |
-
diagnostic) et une instance de ``BaseModule`` fournie par
|
| 117 |
-
l'utilisateur. Les types d'entrée et de sortie ne sont pas
|
| 118 |
-
redéclarés ici : ils sont lus depuis le module lui-même
|
| 119 |
-
(``module.input_types`` / ``module.output_types``).
|
| 120 |
-
|
| 121 |
-
Sprint 66 — DAG branchant
|
| 122 |
-
-------------------------
|
| 123 |
-
``inputs_from`` permet de désigner explicitement, pour chaque
|
| 124 |
-
type d'entrée, l'étape source dont on veut consommer l'artefact.
|
| 125 |
-
Utile quand plusieurs étapes antérieures produisent le même
|
| 126 |
-
type et qu'on veut éviter l'écrasement implicite (par exemple
|
| 127 |
-
deux correcteurs LLM en parallèle qui partent du même OCR).
|
| 128 |
-
|
| 129 |
-
- ``inputs_from = {}`` (défaut) : pour chaque type d'entrée,
|
| 130 |
-
le runner prend la version **la plus récente** disponible
|
| 131 |
-
dans le bag (comportement Sprint 63, rétrocompat stricte).
|
| 132 |
-
- ``inputs_from = {ArtifactType.TEXT: "ocr"}`` : exige la
|
| 133 |
-
version du ``TEXT`` produite par l'étape nommée ``"ocr"``.
|
| 134 |
-
Si cette étape n'existe pas ou n'a pas produit ce type,
|
| 135 |
-
``PipelineSpec.validate`` remonte un problème explicite et
|
| 136 |
-
le runner remonte une erreur d'entrée manquante.
|
| 137 |
-
|
| 138 |
-
La chaîne spéciale ``"__initial__"`` désigne les artefacts
|
| 139 |
-
fournis dans ``initial_inputs`` (par exemple ``IMAGE``).
|
| 140 |
-
"""
|
| 141 |
-
|
| 142 |
-
name: str
|
| 143 |
-
module: BaseModule
|
| 144 |
-
inputs_from: dict[ArtifactType, str] = field(default_factory=dict)
|
| 145 |
-
|
| 146 |
-
@property
|
| 147 |
-
def input_types(self) -> tuple[ArtifactType, ...]:
|
| 148 |
-
return tuple(self.module.input_types)
|
| 149 |
-
|
| 150 |
-
@property
|
| 151 |
-
def output_types(self) -> tuple[ArtifactType, ...]:
|
| 152 |
-
return tuple(self.module.output_types)
|
| 153 |
-
|
| 154 |
-
def __repr__(self) -> str:
|
| 155 |
-
ins = ",".join(t.value for t in self.input_types) or "·"
|
| 156 |
-
outs = ",".join(t.value for t in self.output_types) or "·"
|
| 157 |
-
if self.inputs_from:
|
| 158 |
-
refs = ",".join(
|
| 159 |
-
f"{t.value}@{src}" for t, src in self.inputs_from.items()
|
| 160 |
-
)
|
| 161 |
-
return f"PipelineStep({self.name}: [{refs}] → {outs})"
|
| 162 |
-
return f"PipelineStep({self.name}: {ins} → {outs})"
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
@dataclass
|
| 166 |
-
class PipelineSpec:
|
| 167 |
-
"""DAG séquentiel de ``PipelineStep``.
|
| 168 |
-
|
| 169 |
-
Sprint 63 — séquentiel uniquement : l'étape ``i+1`` consomme
|
| 170 |
-
les artefacts produits par l'étape ``i`` (et tous les artefacts
|
| 171 |
-
initiaux fournis au runner, par exemple l'image source).
|
| 172 |
-
|
| 173 |
-
Le DAG branchant arrive dans un sprint dédié.
|
| 174 |
-
"""
|
| 175 |
-
|
| 176 |
-
name: str
|
| 177 |
-
steps: list[PipelineStep] = field(default_factory=list)
|
| 178 |
-
|
| 179 |
-
def validate(self, initial_inputs: tuple[ArtifactType, ...]) -> list[str]:
|
| 180 |
-
"""Vérifie que les types s'enchaînent et retourne la liste
|
| 181 |
-
des problèmes détectés (vide si la pipeline est valide).
|
| 182 |
-
|
| 183 |
-
Une pipeline est valide si, pour chaque étape, tous les
|
| 184 |
-
``input_types`` sont disponibles : soit dans les
|
| 185 |
-
``initial_inputs`` (typiquement ``IMAGE``), soit produits
|
| 186 |
-
par une étape antérieure.
|
| 187 |
-
|
| 188 |
-
Sprint 66 — validation des références ``inputs_from`` :
|
| 189 |
-
si une étape déclare ``inputs_from[type] = "foo"``,
|
| 190 |
-
l'étape ``foo`` doit exister parmi les étapes antérieures
|
| 191 |
-
et avoir ce type dans ses ``output_types``. La chaîne
|
| 192 |
-
spéciale ``"__initial__"`` désigne les entrées initiales.
|
| 193 |
-
"""
|
| 194 |
-
problems: list[str] = []
|
| 195 |
-
if not self.steps:
|
| 196 |
-
problems.append("pipeline vide : au moins une étape est requise")
|
| 197 |
-
return problems
|
| 198 |
-
# Map type → set des steps qui ont produit ce type
|
| 199 |
-
# ("__initial__" pour les entrées initiales) — utilisé pour
|
| 200 |
-
# valider les références ``inputs_from``.
|
| 201 |
-
producers: dict[ArtifactType, set[str]] = {
|
| 202 |
-
t: {"__initial__"} for t in initial_inputs
|
| 203 |
-
}
|
| 204 |
-
# Map step_name → set des types produits, pour la validation
|
| 205 |
-
# des références.
|
| 206 |
-
step_outputs: dict[str, set[ArtifactType]] = {
|
| 207 |
-
"__initial__": set(initial_inputs),
|
| 208 |
-
}
|
| 209 |
-
# Set des types disponibles à un instant t (latest seulement).
|
| 210 |
-
available: set[ArtifactType] = set(initial_inputs)
|
| 211 |
-
|
| 212 |
-
for i, step in enumerate(self.steps):
|
| 213 |
-
# 1. Toutes les entrées doivent être disponibles
|
| 214 |
-
missing = [t for t in step.input_types if t not in available]
|
| 215 |
-
if missing:
|
| 216 |
-
miss_str = ",".join(t.value for t in missing)
|
| 217 |
-
problems.append(
|
| 218 |
-
f"étape {i} ({step.name}) demande {miss_str} "
|
| 219 |
-
f"qui n'est ni dans les entrées initiales "
|
| 220 |
-
f"ni produit par une étape antérieure"
|
| 221 |
-
)
|
| 222 |
-
# 2. Vérification des références ``inputs_from``
|
| 223 |
-
for ref_type, ref_step in step.inputs_from.items():
|
| 224 |
-
if ref_type not in step.input_types:
|
| 225 |
-
problems.append(
|
| 226 |
-
f"étape {i} ({step.name}) déclare "
|
| 227 |
-
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 228 |
-
f"mais le module ne consomme pas ce type"
|
| 229 |
-
)
|
| 230 |
-
continue
|
| 231 |
-
if ref_step not in step_outputs:
|
| 232 |
-
problems.append(
|
| 233 |
-
f"étape {i} ({step.name}) référence "
|
| 234 |
-
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 235 |
-
f"qui n'est pas une étape antérieure connue"
|
| 236 |
-
)
|
| 237 |
-
continue
|
| 238 |
-
if ref_type not in step_outputs[ref_step]:
|
| 239 |
-
problems.append(
|
| 240 |
-
f"étape {i} ({step.name}) référence "
|
| 241 |
-
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 242 |
-
f"mais cette étape ne produit pas ce type"
|
| 243 |
-
)
|
| 244 |
-
# 3. Mise à jour pour les étapes suivantes
|
| 245 |
-
available.update(step.output_types)
|
| 246 |
-
step_outputs[step.name] = set(step.output_types)
|
| 247 |
-
for out_type in step.output_types:
|
| 248 |
-
producers.setdefault(out_type, set()).add(step.name)
|
| 249 |
-
return problems
|
| 250 |
-
|
| 251 |
-
def is_valid(self, initial_inputs: tuple[ArtifactType, ...]) -> bool:
|
| 252 |
-
return not self.validate(initial_inputs)
|
| 253 |
-
|
| 254 |
-
def __repr__(self) -> str:
|
| 255 |
-
chain = " → ".join(str(s) for s in self.steps)
|
| 256 |
-
return f"PipelineSpec({self.name}: {chain})"
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 260 |
-
# StepResult + PipelineResult
|
| 261 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
@dataclass
|
| 265 |
-
class StepResult:
|
| 266 |
-
"""Résultat de l'exécution d'une étape sur un document.
|
| 267 |
-
|
| 268 |
-
Champs
|
| 269 |
-
------
|
| 270 |
-
step_name:
|
| 271 |
-
Nom de l'étape (cf. ``PipelineStep.name``).
|
| 272 |
-
duration_seconds:
|
| 273 |
-
Temps d'exécution de ``module.process`` mesuré en wall-clock.
|
| 274 |
-
output_types:
|
| 275 |
-
Types effectivement présents dans la sortie (peut être un
|
| 276 |
-
sous-ensemble de ``module.output_types`` si le module a
|
| 277 |
-
omis un type — cas reporté ici comme info pour diagnostic).
|
| 278 |
-
junction_metrics:
|
| 279 |
-
Pour chaque type produit qui correspond à un ``GTLevel``
|
| 280 |
-
dont le document porte une GT : dictionnaire ``{type: dict
|
| 281 |
-
métriques}`` retourné par ``compute_at_junction``.
|
| 282 |
-
error:
|
| 283 |
-
``None`` si l'étape s'est bien déroulée ; sinon message
|
| 284 |
-
d'erreur (le module a levé, l'entrée est manquante, ou la
|
| 285 |
-
validation des types a échoué).
|
| 286 |
-
"""
|
| 287 |
-
|
| 288 |
-
step_name: str
|
| 289 |
-
duration_seconds: float
|
| 290 |
-
output_types: tuple[ArtifactType, ...]
|
| 291 |
-
junction_metrics: dict[str, dict[str, Any]] = field(default_factory=dict)
|
| 292 |
-
"""Map ``{artifact_type_value: {metric_name: value}}``.
|
| 293 |
-
|
| 294 |
-
La clé est la valeur string du ``ArtifactType`` (ex. ``"text"``,
|
| 295 |
-
``"alto"``) et non l'enum lui-même, pour faciliter la
|
| 296 |
-
sérialisation JSON.
|
| 297 |
-
"""
|
| 298 |
-
error: Optional[str] = None
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
@dataclass
|
| 302 |
-
class PipelineResult:
|
| 303 |
-
"""Résultat complet d'une exécution de pipeline sur un document.
|
| 304 |
-
|
| 305 |
-
On capture la durée totale, la durée par étape et les
|
| 306 |
-
métriques aux jonctions pour chaque artefact produit qui a une
|
| 307 |
-
GT correspondante.
|
| 308 |
-
"""
|
| 309 |
-
|
| 310 |
-
pipeline_name: str
|
| 311 |
-
doc_id: str
|
| 312 |
-
steps: list[StepResult] = field(default_factory=list)
|
| 313 |
-
total_duration_seconds: float = 0.0
|
| 314 |
-
error: Optional[str] = None
|
| 315 |
-
"""Erreur fatale au niveau pipeline (ex. validation des types
|
| 316 |
-
en amont avant la première étape). ``None`` n'implique pas
|
| 317 |
-
qu'aucune étape n'a échoué — voir ``StepResult.error`` pour le
|
| 318 |
-
détail par étape."""
|
| 319 |
-
|
| 320 |
-
@property
|
| 321 |
-
def succeeded(self) -> bool:
|
| 322 |
-
"""Vrai si la pipeline s'est exécutée jusqu'au bout sans
|
| 323 |
-
qu'aucune étape ne lève d'erreur."""
|
| 324 |
-
if self.error is not None:
|
| 325 |
-
return False
|
| 326 |
-
return all(s.error is None for s in self.steps)
|
| 327 |
-
|
| 328 |
-
@property
|
| 329 |
-
def failing_steps(self) -> list[str]:
|
| 330 |
-
"""Noms des étapes ayant levé une erreur."""
|
| 331 |
-
return [s.step_name for s in self.steps if s.error is not None]
|
| 332 |
-
|
| 333 |
-
def junction_metrics_for(
|
| 334 |
-
self, artifact_type: ArtifactType,
|
| 335 |
-
) -> Optional[dict[str, Any]]:
|
| 336 |
-
"""Retourne les métriques de la **dernière** étape qui a
|
| 337 |
-
produit ``artifact_type``, ou ``None`` si aucune étape ne
|
| 338 |
-
l'a produit avec succès.
|
| 339 |
-
|
| 340 |
-
Utile pour comparer plusieurs pipelines qui produisent in
|
| 341 |
-
fine le même type (ex. deux DAG aboutissant à du texte
|
| 342 |
-
corrigé).
|
| 343 |
-
"""
|
| 344 |
-
from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
|
| 345 |
-
legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
|
| 346 |
-
for step in reversed(self.steps):
|
| 347 |
-
if step.error is not None:
|
| 348 |
-
continue
|
| 349 |
-
metrics = step.junction_metrics.get(artifact_type.value)
|
| 350 |
-
if metrics is None and legacy_alias is not None:
|
| 351 |
-
# Phase 4-bis : un caller legacy peut avoir construit
|
| 352 |
-
# le dict avec la clé pré-rewrite ("text" au lieu de
|
| 353 |
-
# "raw_text"). expand_legacy_keys synchronise les deux
|
| 354 |
-
# côtés sur les sites d'écriture du runner, mais des
|
| 355 |
-
# StepResult construits à la main par les tests ou par
|
| 356 |
-
# un caller externe peuvent encore avoir une seule
|
| 357 |
-
# clé — on tolère.
|
| 358 |
-
metrics = step.junction_metrics.get(legacy_alias)
|
| 359 |
-
if metrics is not None:
|
| 360 |
-
return metrics
|
| 361 |
-
return None
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 365 |
-
# Exécuteur
|
| 366 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
class PipelineRunner:
|
| 370 |
-
"""Exécute une ``PipelineSpec`` sur un document.
|
| 371 |
-
|
| 372 |
-
Sprint 63 — un seul document à la fois. L'orchestration
|
| 373 |
-
corpus-wide et l'agrégation par pipeline sont reportées à un
|
| 374 |
-
sprint dédié.
|
| 375 |
-
|
| 376 |
-
Usage typique
|
| 377 |
-
-------------
|
| 378 |
-
|
| 379 |
-
>>> spec = PipelineSpec(
|
| 380 |
-
... name="ocr_then_rewrite",
|
| 381 |
-
... steps=[
|
| 382 |
-
... PipelineStep("ocr", my_ocr_module),
|
| 383 |
-
... PipelineStep("rewrite", my_llm_rewriter),
|
| 384 |
-
... ],
|
| 385 |
-
... )
|
| 386 |
-
>>> runner = PipelineRunner()
|
| 387 |
-
>>> result = runner.run(spec, document, {ArtifactType.IMAGE: "/path/img.png"})
|
| 388 |
-
>>> result.succeeded
|
| 389 |
-
True
|
| 390 |
-
>>> result.junction_metrics_for(ArtifactType.TEXT)
|
| 391 |
-
{'cer': 0.05, 'wer': 0.12, ...}
|
| 392 |
-
"""
|
| 393 |
-
|
| 394 |
-
@staticmethod
|
| 395 |
-
def run(
|
| 396 |
-
spec: PipelineSpec,
|
| 397 |
-
document: Document,
|
| 398 |
-
initial_inputs: dict[ArtifactType, Any],
|
| 399 |
-
) -> PipelineResult:
|
| 400 |
-
"""Exécute ``spec`` sur ``document`` à partir de
|
| 401 |
-
``initial_inputs``.
|
| 402 |
-
|
| 403 |
-
Parameters
|
| 404 |
-
----------
|
| 405 |
-
spec:
|
| 406 |
-
Spécification de la pipeline.
|
| 407 |
-
document:
|
| 408 |
-
Document du corpus, porteur de zéro ou plusieurs niveaux
|
| 409 |
-
de GT (Sprint 32).
|
| 410 |
-
initial_inputs:
|
| 411 |
-
Artefacts initiaux par type — typiquement
|
| 412 |
-
``{ArtifactType.IMAGE: "/path/img.png"}`` pour une
|
| 413 |
-
pipeline qui démarre par un OCR.
|
| 414 |
-
|
| 415 |
-
Returns
|
| 416 |
-
-------
|
| 417 |
-
PipelineResult
|
| 418 |
-
Résultat complet : durée totale, résultat par étape,
|
| 419 |
-
métriques aux jonctions évaluées contre la GT.
|
| 420 |
-
"""
|
| 421 |
-
result = PipelineResult(
|
| 422 |
-
pipeline_name=spec.name, doc_id=document.doc_id,
|
| 423 |
-
)
|
| 424 |
-
|
| 425 |
-
# Validation amont : si la pipeline est statiquement
|
| 426 |
-
# invalide, on n'exécute aucune étape.
|
| 427 |
-
problems = spec.validate(tuple(initial_inputs.keys()))
|
| 428 |
-
if problems:
|
| 429 |
-
result.error = " ; ".join(problems)
|
| 430 |
-
return result
|
| 431 |
-
|
| 432 |
-
# Sprint 66 — bag versionné : ``versioned[(type, src_step)]``
|
| 433 |
-
# contient l'artefact produit par ``src_step`` pour ``type``.
|
| 434 |
-
# ``src_step`` vaut ``"__initial__"`` pour les entrées
|
| 435 |
-
# initiales fournies par l'utilisateur. ``latest[type]``
|
| 436 |
-
# désigne le nom de l'étape qui a produit la version la plus
|
| 437 |
-
# récente du type — utilisé en l'absence d'``inputs_from``
|
| 438 |
-
# explicite (rétrocompat Sprint 63).
|
| 439 |
-
versioned: dict[tuple[ArtifactType, str], Any] = {
|
| 440 |
-
(t, "__initial__"): v for t, v in initial_inputs.items()
|
| 441 |
-
}
|
| 442 |
-
latest: dict[ArtifactType, str] = {
|
| 443 |
-
t: "__initial__" for t in initial_inputs
|
| 444 |
-
}
|
| 445 |
-
|
| 446 |
-
pipeline_t0 = time.monotonic()
|
| 447 |
-
for step in spec.steps:
|
| 448 |
-
step_result = PipelineRunner._run_step(
|
| 449 |
-
step, versioned, latest, document,
|
| 450 |
-
)
|
| 451 |
-
result.steps.append(step_result)
|
| 452 |
-
result.total_duration_seconds = time.monotonic() - pipeline_t0
|
| 453 |
-
return result
|
| 454 |
-
|
| 455 |
-
@staticmethod
|
| 456 |
-
def _run_step(
|
| 457 |
-
step: PipelineStep,
|
| 458 |
-
versioned: dict[tuple[ArtifactType, str], Any],
|
| 459 |
-
latest: dict[ArtifactType, str],
|
| 460 |
-
document: Document,
|
| 461 |
-
) -> StepResult:
|
| 462 |
-
# Sprint 66 — résolution des entrées : pour chaque type
|
| 463 |
-
# demandé, on consulte ``inputs_from`` ; sinon on prend la
|
| 464 |
-
# dernière version disponible (rétrocompat Sprint 63).
|
| 465 |
-
resolved: dict[ArtifactType, Any] = {}
|
| 466 |
-
missing: list[str] = []
|
| 467 |
-
for t in step.input_types:
|
| 468 |
-
src = step.inputs_from.get(t, latest.get(t))
|
| 469 |
-
if src is None:
|
| 470 |
-
missing.append(t.value)
|
| 471 |
-
continue
|
| 472 |
-
key = (t, src)
|
| 473 |
-
if key not in versioned:
|
| 474 |
-
# Référence explicite vers une étape qui n'a pas
|
| 475 |
-
# produit cet artefact (ex. l'étape source a échoué).
|
| 476 |
-
missing.append(f"{t.value}@{src}")
|
| 477 |
-
continue
|
| 478 |
-
resolved[t] = versioned[key]
|
| 479 |
-
if missing:
|
| 480 |
-
miss_str = ",".join(missing)
|
| 481 |
-
return StepResult(
|
| 482 |
-
step_name=step.name,
|
| 483 |
-
duration_seconds=0.0,
|
| 484 |
-
output_types=(),
|
| 485 |
-
error=f"entrée manquante : {miss_str}",
|
| 486 |
-
)
|
| 487 |
-
inputs_for_module = resolved
|
| 488 |
-
# Exécution chronométrée
|
| 489 |
-
t0 = time.monotonic()
|
| 490 |
-
try:
|
| 491 |
-
outputs = step.module.process(inputs_for_module)
|
| 492 |
-
except Exception as exc: # noqa: BLE001
|
| 493 |
-
duration = time.monotonic() - t0
|
| 494 |
-
logger.warning(
|
| 495 |
-
"[pipeline_runner] étape '%s' a levé : %s",
|
| 496 |
-
step.name, exc,
|
| 497 |
-
)
|
| 498 |
-
return StepResult(
|
| 499 |
-
step_name=step.name,
|
| 500 |
-
duration_seconds=duration,
|
| 501 |
-
output_types=(),
|
| 502 |
-
error=f"{type(exc).__name__}: {exc}",
|
| 503 |
-
)
|
| 504 |
-
duration = time.monotonic() - t0
|
| 505 |
-
|
| 506 |
-
# Validation des sorties : le module est censé déclarer ses
|
| 507 |
-
# output_types, on vérifie qu'il les a tous produits. Si
|
| 508 |
-
# ce n'est pas le cas, on remonte une erreur explicite mais
|
| 509 |
-
# on conserve les sorties effectivement présentes (utile
|
| 510 |
-
# pour le diagnostic).
|
| 511 |
-
if not isinstance(outputs, dict):
|
| 512 |
-
return StepResult(
|
| 513 |
-
step_name=step.name,
|
| 514 |
-
duration_seconds=duration,
|
| 515 |
-
output_types=(),
|
| 516 |
-
error=(
|
| 517 |
-
f"le module a retourné {type(outputs).__name__}, "
|
| 518 |
-
f"un dict[ArtifactType, Any] est attendu"
|
| 519 |
-
),
|
| 520 |
-
)
|
| 521 |
-
produced = tuple(t for t in step.output_types if t in outputs)
|
| 522 |
-
missing_outputs = [t for t in step.output_types if t not in outputs]
|
| 523 |
-
error: Optional[str] = None
|
| 524 |
-
if missing_outputs:
|
| 525 |
-
miss_str = ",".join(t.value for t in missing_outputs)
|
| 526 |
-
error = f"sortie manquante : {miss_str}"
|
| 527 |
-
|
| 528 |
-
# Mise à jour du bag versionné : on stocke la sortie sous
|
| 529 |
-
# une clé (type, step.name) ET on met à jour ``latest`` pour
|
| 530 |
-
# que les étapes suivantes la récupèrent par défaut.
|
| 531 |
-
for t in produced:
|
| 532 |
-
versioned[(t, step.name)] = outputs[t]
|
| 533 |
-
latest[t] = step.name
|
| 534 |
-
|
| 535 |
-
# Évaluation aux jonctions : pour chaque type produit, si
|
| 536 |
-
# la GT du même niveau existe, on calcule les métriques.
|
| 537 |
-
junction_metrics: dict[str, dict[str, Any]] = {}
|
| 538 |
-
for at in produced:
|
| 539 |
-
gt_level = _artifact_type_to_gt_level(at)
|
| 540 |
-
if gt_level is None:
|
| 541 |
-
continue
|
| 542 |
-
gt_payload = document.get_gt(gt_level)
|
| 543 |
-
if gt_payload is None:
|
| 544 |
-
continue
|
| 545 |
-
try:
|
| 546 |
-
metrics = compute_at_junction(
|
| 547 |
-
_gt_payload_to_value(gt_payload),
|
| 548 |
-
outputs[at],
|
| 549 |
-
(at, at),
|
| 550 |
-
)
|
| 551 |
-
except Exception as exc: # noqa: BLE001
|
| 552 |
-
logger.warning(
|
| 553 |
-
"[pipeline_runner] évaluation à la jonction %s "
|
| 554 |
-
"a levé : %s",
|
| 555 |
-
at.value, exc,
|
| 556 |
-
)
|
| 557 |
-
continue
|
| 558 |
-
if metrics:
|
| 559 |
-
junction_metrics[at.value] = metrics
|
| 560 |
-
|
| 561 |
-
# Phase 4-bis : double-clé pour rétrocompat. Les tests
|
| 562 |
-
# legacy cherchent junction_metrics["text"] mais le runner
|
| 563 |
-
# peut produire junction_metrics["raw_text"] si l'enum est
|
| 564 |
-
# migré (ArtifactType.TEXT alias de RAW_TEXT, valeur
|
| 565 |
-
# "raw_text"). expand_legacy_keys ajoute la clé legacy
|
| 566 |
-
# ("text") à côté de la canonique ("raw_text") sans écraser.
|
| 567 |
-
from picarones.domain.artifacts import expand_legacy_keys
|
| 568 |
-
expand_legacy_keys(junction_metrics)
|
| 569 |
-
|
| 570 |
-
return StepResult(
|
| 571 |
-
step_name=step.name,
|
| 572 |
-
duration_seconds=duration,
|
| 573 |
-
output_types=produced,
|
| 574 |
-
junction_metrics=junction_metrics,
|
| 575 |
-
error=error,
|
| 576 |
-
)
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
def _gt_payload_to_value(payload: Any) -> Any:
|
| 580 |
-
"""Extrait la valeur exploitable d'un ``GTPayload`` typé.
|
| 581 |
-
|
| 582 |
-
Pour ``TextGT`` on veut juste la chaîne ; pour les autres
|
| 583 |
-
payloads on retourne le payload entier (la métrique sait quoi
|
| 584 |
-
en faire selon sa signature de types).
|
| 585 |
-
"""
|
| 586 |
-
# Import paresseux pour éviter une dépendance cyclique
|
| 587 |
-
from picarones.evaluation.corpus import (
|
| 588 |
-
AltoGT, EntitiesGT, PageGT, ReadingOrderGT, TextGT,
|
| 589 |
-
)
|
| 590 |
-
if isinstance(payload, TextGT):
|
| 591 |
-
return payload.text
|
| 592 |
-
if isinstance(payload, EntitiesGT):
|
| 593 |
-
return payload.entities
|
| 594 |
-
if isinstance(payload, ReadingOrderGT):
|
| 595 |
-
return payload.region_order
|
| 596 |
-
if isinstance(payload, (AltoGT, PageGT)):
|
| 597 |
-
return payload
|
| 598 |
-
return payload
|
| 599 |
|
|
|
|
| 600 |
|
| 601 |
-
|
| 602 |
-
"
|
| 603 |
-
"
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
]
|
|
|
|
| 1 |
+
"""``picarones.core.pipeline`` — shim re-export (déprécié, suppression 2.0).
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.evaluation.pipeline`. Phase 5.C.batch7
|
| 4 |
+
du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.evaluation.pipeline import * # noqa: F401, F403
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.core.pipeline is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.evaluation.pipeline instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
| 18 |
+
)
|
|
|
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Précision sur séquences numériques — Sprint 85 (A.II.5b).
|
| 2 |
+
|
| 3 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 4 |
+
``picarones.measurements.numerical_sequences`` vers
|
| 5 |
+
``picarones.evaluation.metrics.numerical_sequences``. Le chemin
|
| 6 |
+
legacy reste disponible via un shim avec ``DeprecationWarning`` ;
|
| 7 |
+
suppression prévue en 2.0.
|
| 8 |
+
|
| 9 |
+
Sprint 85 — A.II.5b du plan d'évolution 2026.
|
| 10 |
+
|
| 11 |
+
Pourquoi ce module
|
| 12 |
+
------------------
|
| 13 |
+
Pour un économiste-historien, un éditeur de chartes ou un
|
| 14 |
+
archiviste, la **fidélité aux séquences numériques** est un
|
| 15 |
+
proxy direct de la qualité éditoriale. Un OCR qui rate
|
| 16 |
+
*« 1789 »* dans une charte révolutionnaire ou *« f. 12v »*
|
| 17 |
+
dans une cote d'archives produit un corpus inutilisable pour la
|
| 18 |
+
recherche fine, même si le CER global est respectable.
|
| 19 |
+
|
| 20 |
+
Catégories couvertes
|
| 21 |
+
--------------------
|
| 22 |
+
1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789``
|
| 23 |
+
(le module détecte les **années** sur 4 chiffres dans la
|
| 24 |
+
plage [1000-2099]).
|
| 25 |
+
2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``.
|
| 26 |
+
Réutilise ``picarones.measurements.roman_numerals`` (Sprint 60).
|
| 27 |
+
3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``,
|
| 28 |
+
``p. 5``, ``pp. 12-15``, ``n° 42``.
|
| 29 |
+
4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``,
|
| 30 |
+
``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime
|
| 31 |
+
(``l.``, ``s.``, ``d.``).
|
| 32 |
+
5. **Années régnales** : ``an III``, ``l'an V``, ``an de
|
| 33 |
+
grâce 1450``, ``an de la République``.
|
| 34 |
+
|
| 35 |
+
Méthode
|
| 36 |
+
-------
|
| 37 |
+
Pour chaque catégorie, on extrait les occurrences (regex
|
| 38 |
+
spécialisée) en GT et en hypothèse. On classe ensuite chaque
|
| 39 |
+
GT en **3 statuts** :
|
| 40 |
+
|
| 41 |
+
- ``strict_preserved`` : forme exacte présente dans
|
| 42 |
+
l'hypothèse (sensible à la casse seulement pour la
|
| 43 |
+
foliotation, sinon la convention est documentée par
|
| 44 |
+
catégorie) ;
|
| 45 |
+
- ``value_preserved`` : la **valeur** apparaît même si la
|
| 46 |
+
forme diffère (ex. ``XIV`` GT et ``14`` hypothèse —
|
| 47 |
+
considéré comme valeur préservée mais forme non) ;
|
| 48 |
+
- ``lost`` : aucune trace exploitable.
|
| 49 |
+
|
| 50 |
+
Sortie
|
| 51 |
+
------
|
| 52 |
+
``compute_numerical_sequence_metrics(reference, hypothesis)``
|
| 53 |
+
retourne :
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
{
|
| 57 |
+
"global_strict_score": float, # ∈ [0, 1]
|
| 58 |
+
"global_value_score": float, # ∈ [0, 1]
|
| 59 |
+
"n_total": int,
|
| 60 |
+
"per_category": {
|
| 61 |
+
"year": {"n_total": int, "strict": int, "value": int,
|
| 62 |
+
"strict_score": float, "value_score": float,
|
| 63 |
+
"lost_items": list[str]},
|
| 64 |
+
"roman": {...},
|
| 65 |
+
"foliation": {...},
|
| 66 |
+
"currency": {...},
|
| 67 |
+
"regnal": {...},
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Limites
|
| 73 |
+
-------
|
| 74 |
+
- Les regex sont **conservatrices** : on rate quelques
|
| 75 |
+
formes rares plutôt que de produire des faux positifs (par
|
| 76 |
+
exemple, ``mil cinq cens`` en français médiéval n'est pas
|
| 77 |
+
détecté comme année — la couche calcul s'en tient aux
|
| 78 |
+
formes les plus reconnaissables). Pour un corpus
|
| 79 |
+
spécifique, l'utilisateur peut composer ses propres
|
| 80 |
+
détecteurs et les passer via ``custom_detectors``.
|
| 81 |
+
- ``value_preserved`` exige une équivalence de **valeur
|
| 82 |
+
numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ;
|
| 83 |
+
``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la
|
| 84 |
+
foliotation (recto/verso est une information distincte).
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
from __future__ import annotations
|
| 88 |
+
|
| 89 |
+
import logging
|
| 90 |
+
import re
|
| 91 |
+
from typing import Optional
|
| 92 |
+
|
| 93 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 94 |
+
from picarones.domain.artifacts import ArtifactType
|
| 95 |
+
from picarones.evaluation.metrics.roman_numerals import (
|
| 96 |
+
detect_roman_numerals,
|
| 97 |
+
roman_to_int,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
logger = logging.getLogger(__name__)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 104 |
+
# Constantes / catégories
|
| 105 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
CATEGORIES = ("year", "roman", "foliation", "currency", "regnal")
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Dates arabes — 4 chiffres dans la plage [1000-2099].
|
| 112 |
+
# On exige une frontière de mot pour ne pas attraper
|
| 113 |
+
# « 12345 » (volume) ou « 0001 » (numéro de page).
|
| 114 |
+
_RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42
|
| 118 |
+
# La capture conserve la forme intégrale (avec ponctuation et
|
| 119 |
+
# r/v) parce que recto/verso est une information distincte.
|
| 120 |
+
_RE_FOLIATION = re.compile(
|
| 121 |
+
r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n°
|
| 122 |
+
r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15)
|
| 123 |
+
r"\s*([rvRV])?", # suffixe optionnel r/v
|
| 124 |
+
re.UNICODE,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# Montants : nombre suivi d'une unité monétaire.
|
| 129 |
+
# On accepte espaces multiples mais pas de saut de ligne.
|
| 130 |
+
_RE_CURRENCY = re.compile(
|
| 131 |
+
r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal)
|
| 132 |
+
r"(livres?|sols?|deniers?|écus?|florins?|francs?|"
|
| 133 |
+
r"l\.|s\.|d\.|£|€|₣)" # unité
|
| 134 |
+
r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole
|
| 135 |
+
re.UNICODE | re.IGNORECASE,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# Années régnales : « an III », « an de grâce 1450 »,
|
| 140 |
+
# « l'an V de la République ».
|
| 141 |
+
# Capture le numéral (romain ou arabe).
|
| 142 |
+
_RE_REGNAL = re.compile(
|
| 143 |
+
r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?"
|
| 144 |
+
r"([IVXLCDMivxlcdm]+|\d{1,4})\b",
|
| 145 |
+
re.UNICODE,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 150 |
+
# Détection par catégorie
|
| 151 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _detect_years(text: str) -> list[tuple[str, int]]:
|
| 155 |
+
"""Retourne [(forme, valeur)] pour chaque année 4 chiffres."""
|
| 156 |
+
if not text:
|
| 157 |
+
return []
|
| 158 |
+
return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _detect_romans_with_values(text: str) -> list[tuple[str, int]]:
|
| 162 |
+
"""Numéraux romains accompagnés de leur valeur entière.
|
| 163 |
+
Délègue à ``roman_numerals.detect_roman_numerals`` (Sprint 60),
|
| 164 |
+
qui retourne ``(start, form, value)``.
|
| 165 |
+
"""
|
| 166 |
+
if not text:
|
| 167 |
+
return []
|
| 168 |
+
out: list[tuple[str, int]] = []
|
| 169 |
+
for _start, form, value in detect_roman_numerals(text, min_length=2):
|
| 170 |
+
if value is not None:
|
| 171 |
+
out.append((form, value))
|
| 172 |
+
return out
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _detect_foliations(text: str) -> list[tuple[str, str]]:
|
| 176 |
+
"""Foliotation. Retourne [(forme_complète, clé_normalisée)] où la
|
| 177 |
+
clé inclut le suffixe r/v normalisé (recto/verso).
|
| 178 |
+
"""
|
| 179 |
+
if not text:
|
| 180 |
+
return []
|
| 181 |
+
out: list[tuple[str, str]] = []
|
| 182 |
+
for m in _RE_FOLIATION.finditer(text):
|
| 183 |
+
full = m.group(0).strip()
|
| 184 |
+
nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15"
|
| 185 |
+
suffix = (m.group(2) or "").lower()
|
| 186 |
+
key = f"{nums}{suffix}"
|
| 187 |
+
out.append((full, key))
|
| 188 |
+
return out
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]:
|
| 192 |
+
"""Montants. Clé = (montant_normalisé, unité_canonique).
|
| 193 |
+
|
| 194 |
+
L'unité canonique compresse les variantes (« livres » et
|
| 195 |
+
« livre » → « livre » ; « £ » reste « £ »).
|
| 196 |
+
"""
|
| 197 |
+
if not text:
|
| 198 |
+
return []
|
| 199 |
+
canon = {
|
| 200 |
+
"livre": "livre", "livres": "livre", "l.": "livre",
|
| 201 |
+
"sol": "sol", "sols": "sol", "s.": "sol",
|
| 202 |
+
"denier": "denier", "deniers": "denier", "d.": "denier",
|
| 203 |
+
"écu": "écu", "écus": "écu",
|
| 204 |
+
"florin": "florin", "florins": "florin",
|
| 205 |
+
"franc": "franc", "francs": "franc",
|
| 206 |
+
"£": "£", "€": "€", "₣": "₣",
|
| 207 |
+
}
|
| 208 |
+
out: list[tuple[str, tuple[str, str]]] = []
|
| 209 |
+
for m in _RE_CURRENCY.finditer(text):
|
| 210 |
+
amount = m.group(1).replace(",", ".")
|
| 211 |
+
unit_raw = m.group(2).lower()
|
| 212 |
+
unit = canon.get(unit_raw, unit_raw)
|
| 213 |
+
out.append((m.group(0), (amount, unit)))
|
| 214 |
+
return out
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _detect_regnal(text: str) -> list[tuple[str, int]]:
|
| 218 |
+
"""Années régnales. Retourne [(forme, valeur_int)] avec la
|
| 219 |
+
valeur extraite (romain → int ou arabe → int).
|
| 220 |
+
"""
|
| 221 |
+
if not text:
|
| 222 |
+
return []
|
| 223 |
+
out: list[tuple[str, int]] = []
|
| 224 |
+
for m in _RE_REGNAL.finditer(text):
|
| 225 |
+
numeral = m.group(1)
|
| 226 |
+
value: Optional[int]
|
| 227 |
+
if numeral.isdigit():
|
| 228 |
+
value = int(numeral)
|
| 229 |
+
else:
|
| 230 |
+
value = roman_to_int(numeral)
|
| 231 |
+
if value is not None:
|
| 232 |
+
out.append((m.group(0), value))
|
| 233 |
+
return out
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
_DETECTORS = {
|
| 237 |
+
"year": _detect_years,
|
| 238 |
+
"roman": _detect_romans_with_values,
|
| 239 |
+
"foliation": _detect_foliations,
|
| 240 |
+
"currency": _detect_currencies,
|
| 241 |
+
"regnal": _detect_regnal,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 246 |
+
# Calcul principal
|
| 247 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _classify_per_category(
|
| 251 |
+
gt_items: list,
|
| 252 |
+
hyp_items: list,
|
| 253 |
+
*,
|
| 254 |
+
form_extractor,
|
| 255 |
+
value_extractor,
|
| 256 |
+
) -> dict:
|
| 257 |
+
"""Pour chaque item GT, le classe en strict_preserved /
|
| 258 |
+
value_preserved / lost.
|
| 259 |
+
|
| 260 |
+
Multiplicité respectée : un item hypothèse ne peut servir
|
| 261 |
+
qu'à un seul match (forme prioritaire sur valeur).
|
| 262 |
+
"""
|
| 263 |
+
hyp_used = [False] * len(hyp_items)
|
| 264 |
+
n_strict = 0
|
| 265 |
+
n_value = 0
|
| 266 |
+
lost: list[str] = []
|
| 267 |
+
# Première passe : matchs stricts (forme exacte)
|
| 268 |
+
matched: list[bool] = [False] * len(gt_items)
|
| 269 |
+
for gi, gt_item in enumerate(gt_items):
|
| 270 |
+
gt_form = form_extractor(gt_item)
|
| 271 |
+
for hi, hyp_item in enumerate(hyp_items):
|
| 272 |
+
if hyp_used[hi]:
|
| 273 |
+
continue
|
| 274 |
+
if form_extractor(hyp_item) == gt_form:
|
| 275 |
+
hyp_used[hi] = True
|
| 276 |
+
matched[gi] = True
|
| 277 |
+
n_strict += 1
|
| 278 |
+
break
|
| 279 |
+
# Deuxième passe : matchs sur valeur (forme différente)
|
| 280 |
+
for gi, gt_item in enumerate(gt_items):
|
| 281 |
+
if matched[gi]:
|
| 282 |
+
n_value += 1 # strict implique value
|
| 283 |
+
continue
|
| 284 |
+
gt_val = value_extractor(gt_item)
|
| 285 |
+
for hi, hyp_item in enumerate(hyp_items):
|
| 286 |
+
if hyp_used[hi]:
|
| 287 |
+
continue
|
| 288 |
+
if value_extractor(hyp_item) == gt_val:
|
| 289 |
+
hyp_used[hi] = True
|
| 290 |
+
matched[gi] = True
|
| 291 |
+
n_value += 1
|
| 292 |
+
break
|
| 293 |
+
if not matched[gi]:
|
| 294 |
+
lost.append(form_extractor(gt_item))
|
| 295 |
+
n_total = len(gt_items)
|
| 296 |
+
return {
|
| 297 |
+
"n_total": n_total,
|
| 298 |
+
"strict": n_strict,
|
| 299 |
+
"value": n_value,
|
| 300 |
+
"strict_score": n_strict / n_total if n_total else 0.0,
|
| 301 |
+
"value_score": n_value / n_total if n_total else 0.0,
|
| 302 |
+
"lost_items": lost,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def compute_numerical_sequence_metrics(
|
| 307 |
+
reference: Optional[str],
|
| 308 |
+
hypothesis: Optional[str],
|
| 309 |
+
) -> dict:
|
| 310 |
+
"""Calcule la précision sur séquences numériques.
|
| 311 |
+
|
| 312 |
+
Returns
|
| 313 |
+
-------
|
| 314 |
+
dict
|
| 315 |
+
Voir docstring du module. Si ``reference`` est vide
|
| 316 |
+
ou ne contient aucune séquence détectée, retourne
|
| 317 |
+
``{n_total: 0, ...}`` avec scores à 0 (pas None).
|
| 318 |
+
"""
|
| 319 |
+
ref = reference or ""
|
| 320 |
+
hyp = hypothesis or ""
|
| 321 |
+
|
| 322 |
+
# Spécifications par catégorie : (gt_items, hyp_items,
|
| 323 |
+
# extractor de forme, extractor de valeur).
|
| 324 |
+
specs: dict[str, dict] = {}
|
| 325 |
+
# year : (form="1789", value=1789)
|
| 326 |
+
specs["year"] = {
|
| 327 |
+
"gt": _detect_years(ref),
|
| 328 |
+
"hyp": _detect_years(hyp),
|
| 329 |
+
"form": lambda it: it[0],
|
| 330 |
+
"value": lambda it: it[1],
|
| 331 |
+
}
|
| 332 |
+
# roman : (form="MDCLXVIII", value=1668)
|
| 333 |
+
specs["roman"] = {
|
| 334 |
+
"gt": _detect_romans_with_values(ref),
|
| 335 |
+
"hyp": _detect_romans_with_values(hyp),
|
| 336 |
+
"form": lambda it: it[0],
|
| 337 |
+
"value": lambda it: it[1],
|
| 338 |
+
}
|
| 339 |
+
# foliation : (form="f. 12r", value="12r")
|
| 340 |
+
specs["foliation"] = {
|
| 341 |
+
"gt": _detect_foliations(ref),
|
| 342 |
+
"hyp": _detect_foliations(hyp),
|
| 343 |
+
"form": lambda it: it[0],
|
| 344 |
+
"value": lambda it: it[1],
|
| 345 |
+
}
|
| 346 |
+
# currency : (form="12 livres", value=("12", "livre"))
|
| 347 |
+
specs["currency"] = {
|
| 348 |
+
"gt": _detect_currencies(ref),
|
| 349 |
+
"hyp": _detect_currencies(hyp),
|
| 350 |
+
"form": lambda it: it[0],
|
| 351 |
+
"value": lambda it: it[1],
|
| 352 |
+
}
|
| 353 |
+
# regnal : (form="an III", value=3)
|
| 354 |
+
specs["regnal"] = {
|
| 355 |
+
"gt": _detect_regnal(ref),
|
| 356 |
+
"hyp": _detect_regnal(hyp),
|
| 357 |
+
"form": lambda it: it[0],
|
| 358 |
+
"value": lambda it: it[1],
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
per_category: dict[str, dict] = {}
|
| 362 |
+
total = 0
|
| 363 |
+
total_strict = 0
|
| 364 |
+
total_value = 0
|
| 365 |
+
for cat, spec in specs.items():
|
| 366 |
+
breakdown = _classify_per_category(
|
| 367 |
+
spec["gt"], spec["hyp"],
|
| 368 |
+
form_extractor=spec["form"],
|
| 369 |
+
value_extractor=spec["value"],
|
| 370 |
+
)
|
| 371 |
+
per_category[cat] = breakdown
|
| 372 |
+
total += breakdown["n_total"]
|
| 373 |
+
total_strict += breakdown["strict"]
|
| 374 |
+
total_value += breakdown["value"]
|
| 375 |
+
|
| 376 |
+
return {
|
| 377 |
+
"n_total": total,
|
| 378 |
+
"global_strict_score": (
|
| 379 |
+
total_strict / total if total else 0.0
|
| 380 |
+
),
|
| 381 |
+
"global_value_score": (
|
| 382 |
+
total_value / total if total else 0.0
|
| 383 |
+
),
|
| 384 |
+
"per_category": per_category,
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 389 |
+
# Enregistrement registre typé
|
| 390 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
@register_metric(
|
| 394 |
+
name="numerical_sequence_strict_score",
|
| 395 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 396 |
+
description=(
|
| 397 |
+
"Précision sur séquences numériques en mode strict (forme "
|
| 398 |
+
"préservée). Couvre années arabes, numéraux romains, "
|
| 399 |
+
"foliotation, montants Ancien Régime, années régnales."
|
| 400 |
+
),
|
| 401 |
+
)
|
| 402 |
+
def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float:
|
| 403 |
+
return compute_numerical_sequence_metrics(
|
| 404 |
+
reference, hypothesis,
|
| 405 |
+
)["global_strict_score"]
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@register_metric(
|
| 409 |
+
name="numerical_sequence_value_score",
|
| 410 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 411 |
+
description=(
|
| 412 |
+
"Précision sur séquences numériques en mode valeur "
|
| 413 |
+
"(la valeur est préservée même si la forme diffère, "
|
| 414 |
+
"ex. XIV → 14)."
|
| 415 |
+
),
|
| 416 |
+
)
|
| 417 |
+
def numerical_sequence_value_score(reference: str, hypothesis: str) -> float:
|
| 418 |
+
return compute_numerical_sequence_metrics(
|
| 419 |
+
reference, hypothesis,
|
| 420 |
+
)["global_value_score"]
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
__all__ = [
|
| 424 |
+
"CATEGORIES",
|
| 425 |
+
"compute_numerical_sequence_metrics",
|
| 426 |
+
"numerical_sequence_strict_score",
|
| 427 |
+
"numerical_sequence_value_score",
|
| 428 |
+
]
|
|
@@ -0,0 +1,484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Numéraux romains — Sprint 60.
|
| 2 |
+
|
| 3 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 4 |
+
``picarones.measurements.roman_numerals`` vers
|
| 5 |
+
``picarones.evaluation.metrics.roman_numerals``. Le chemin legacy
|
| 6 |
+
reste disponible via un shim avec ``DeprecationWarning`` ;
|
| 7 |
+
suppression prévue en 2.0.
|
| 8 |
+
|
| 9 |
+
Sprint 60 — Étape 3 / extension philologique transversale du plan
|
| 10 |
+
d'évolution 2026.
|
| 11 |
+
|
| 12 |
+
Pourquoi ce module
|
| 13 |
+
------------------
|
| 14 |
+
Les numéraux romains traversent **toutes les périodes patrimoniales**
|
| 15 |
+
servies par Picarones :
|
| 16 |
+
|
| 17 |
+
- **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
|
| 18 |
+
(``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
|
| 19 |
+
Convention scribale standard dans les chartes et registres.
|
| 20 |
+
- **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
|
| 21 |
+
- **Moderne** : majuscules pour les souverains (``Louis XIV``) et
|
| 22 |
+
les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
|
| 23 |
+
par le Sprint 59 ``ordinals``, ce module ne traite que la partie
|
| 24 |
+
numérale ``XIX``).
|
| 25 |
+
|
| 26 |
+
Quatre traitements possibles d'un numéral par l'OCR
|
| 27 |
+
----------------------------------------------------
|
| 28 |
+
Pour chaque numéral romain présent dans la GT, l'OCR peut :
|
| 29 |
+
|
| 30 |
+
1. **Préserver strictement** : forme exacte gardée
|
| 31 |
+
(``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
|
| 32 |
+
2. **Préserver en changeant la casse** : la valeur est intacte mais
|
| 33 |
+
la convention typographique est modifiée
|
| 34 |
+
(``xiv`` → ``XIV``). Modernisation typographique courante.
|
| 35 |
+
3. **Préserver en supprimant le ``j`` final** :
|
| 36 |
+
(``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
|
| 37 |
+
médiévale → standard académique moderne.
|
| 38 |
+
4. **Convertir en chiffres arabes** : la valeur est préservée mais
|
| 39 |
+
le système de numération est modernisé
|
| 40 |
+
(``XIV`` → ``14``). Modernisation profonde, perte de
|
| 41 |
+
l'information typographique.
|
| 42 |
+
5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
|
| 43 |
+
|
| 44 |
+
Ce module retourne un breakdown par statut pour que le chercheur
|
| 45 |
+
juge lui-même la convention adoptée par chaque moteur, **sans
|
| 46 |
+
classification automatique imposée**.
|
| 47 |
+
|
| 48 |
+
Stratégie de découpage
|
| 49 |
+
----------------------
|
| 50 |
+
Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
|
| 51 |
+
Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
|
| 52 |
+
Imprimé ancien (58), Archives modernes (59) : couche de calcul
|
| 53 |
+
pure d'abord ; câblage runner et HTML dans des sprints dédiés.
|
| 54 |
+
|
| 55 |
+
Limites documentées
|
| 56 |
+
-------------------
|
| 57 |
+
- Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
|
| 58 |
+
validation par parsing. Les faux positifs restent possibles sur
|
| 59 |
+
des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
|
| 60 |
+
Le paramètre ``min_length`` permet de filtrer les single-letter.
|
| 61 |
+
- Pas de gestion des notations rares avec barre suscript pour
|
| 62 |
+
multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
|
| 63 |
+
corpus patrimonial européen courant.
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
from __future__ import annotations
|
| 67 |
+
|
| 68 |
+
import logging
|
| 69 |
+
import re
|
| 70 |
+
from typing import Optional
|
| 71 |
+
|
| 72 |
+
from picarones.evaluation.metric_registry import register_metric
|
| 73 |
+
from picarones.domain.artifacts import ArtifactType
|
| 74 |
+
|
| 75 |
+
logger = logging.getLogger(__name__)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 79 |
+
# Table de conversion + parsing
|
| 80 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
ROMAN_VALUES: dict[str, int] = {
|
| 83 |
+
"I": 1, "V": 5, "X": 10,
|
| 84 |
+
"L": 50, "C": 100, "D": 500, "M": 1000,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
# Caractères acceptés en entrée (incluant minuscules + j médiéval).
|
| 88 |
+
_ROMAN_CHARS = "IVXLCDMivxlcdmj"
|
| 89 |
+
_ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _normalize_roman(s: str) -> str:
|
| 93 |
+
"""Normalise un numéral romain : majuscule + ``j`` final → ``i``.
|
| 94 |
+
|
| 95 |
+
Les manuscrits médiévaux notent traditionnellement le dernier
|
| 96 |
+
``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
|
| 97 |
+
convertit pour pouvoir parser comme un numéral standard.
|
| 98 |
+
"""
|
| 99 |
+
if not s:
|
| 100 |
+
return ""
|
| 101 |
+
upper = s.upper()
|
| 102 |
+
if upper.endswith("J"):
|
| 103 |
+
upper = upper[:-1] + "I"
|
| 104 |
+
return upper
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _parse_normalized_roman(s: str) -> Optional[int]:
|
| 108 |
+
"""Parse un numéral romain **après normalisation** (majuscule,
|
| 109 |
+
sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
|
| 110 |
+
un numéral romain valide.
|
| 111 |
+
|
| 112 |
+
Validation : on parse en additionnant/soustrayant selon la règle
|
| 113 |
+
classique, puis on **regénère la forme standard** et on compare
|
| 114 |
+
pour rejeter les formes non canoniques (« IIII » au lieu de
|
| 115 |
+
« IV », « VV » au lieu de « X »). Cette stricte validation
|
| 116 |
+
garantit qu'on ne compte pas des séquences absurdes comme
|
| 117 |
+
« XXXX » comme un numéral.
|
| 118 |
+
|
| 119 |
+
Note : les manuscrits médiévaux utilisent fr��quemment « IIII »
|
| 120 |
+
pour 4 (notation soustractive plus tardive). On accepte donc
|
| 121 |
+
aussi cette forme via une règle relâchée : tant que les valeurs
|
| 122 |
+
sont décroissantes ou suivent la règle soustractive standard,
|
| 123 |
+
on accepte.
|
| 124 |
+
"""
|
| 125 |
+
if not s or not all(c in "IVXLCDM" for c in s):
|
| 126 |
+
return None
|
| 127 |
+
# Calcul par soustraction.
|
| 128 |
+
total = 0
|
| 129 |
+
prev_value = 0
|
| 130 |
+
for ch in reversed(s):
|
| 131 |
+
v = ROMAN_VALUES[ch]
|
| 132 |
+
if v < prev_value:
|
| 133 |
+
total -= v
|
| 134 |
+
else:
|
| 135 |
+
total += v
|
| 136 |
+
prev_value = v
|
| 137 |
+
if total <= 0:
|
| 138 |
+
return None
|
| 139 |
+
# Validation relâchée : on accepte les formes médiévales (IIII,
|
| 140 |
+
# VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
|
| 141 |
+
if not _is_plausible_roman(s):
|
| 142 |
+
return None
|
| 143 |
+
return total
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _is_plausible_roman(s: str) -> bool:
|
| 147 |
+
"""Validation relâchée d'un numéral romain (majuscule).
|
| 148 |
+
|
| 149 |
+
On rejette :
|
| 150 |
+
|
| 151 |
+
- 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
|
| 152 |
+
- Les répétitions de V, L, D (jamais répétés en notation
|
| 153 |
+
classique : « VV », « LL », « DD »).
|
| 154 |
+
- Les paires soustractives non standard. En romain canonique,
|
| 155 |
+
seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
|
| 156 |
+
combinaison « petit avant grand » est rejetée. Cela élimine
|
| 157 |
+
les faux positifs sur des mots français comme « ici » (qui
|
| 158 |
+
formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
|
| 159 |
+
"""
|
| 160 |
+
if not s:
|
| 161 |
+
return False
|
| 162 |
+
# Pas de répétitions invalides
|
| 163 |
+
for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
|
| 164 |
+
if forbidden in s:
|
| 165 |
+
return False
|
| 166 |
+
# Paires soustractives autorisées (toutes les autres sont rejetées)
|
| 167 |
+
legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
|
| 168 |
+
for i in range(len(s) - 1):
|
| 169 |
+
a, b = s[i], s[i + 1]
|
| 170 |
+
if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
|
| 171 |
+
if (a + b) not in legal_subtractive:
|
| 172 |
+
return False
|
| 173 |
+
return True
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def roman_to_int(s: Optional[str]) -> Optional[int]:
|
| 177 |
+
"""Convertit une chaîne en numéral romain entier. Tolère casse
|
| 178 |
+
et ``j`` médiéval final. Retourne ``None`` si invalide.
|
| 179 |
+
"""
|
| 180 |
+
if not s:
|
| 181 |
+
return None
|
| 182 |
+
return _parse_normalized_roman(_normalize_roman(s))
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def int_to_roman(n: int) -> str:
|
| 186 |
+
"""Convertit un entier en numéral romain majuscule standard.
|
| 187 |
+
|
| 188 |
+
Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
|
| 189 |
+
forme médiévale relâchée.
|
| 190 |
+
"""
|
| 191 |
+
if n <= 0:
|
| 192 |
+
raise ValueError("n must be positive")
|
| 193 |
+
pairs = [
|
| 194 |
+
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
|
| 195 |
+
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
|
| 196 |
+
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
|
| 197 |
+
(1, "I"),
|
| 198 |
+
]
|
| 199 |
+
out: list[str] = []
|
| 200 |
+
for value, symbol in pairs:
|
| 201 |
+
while n >= value:
|
| 202 |
+
out.append(symbol)
|
| 203 |
+
n -= value
|
| 204 |
+
return "".join(out)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 208 |
+
# Détection dans le texte
|
| 209 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def detect_roman_numerals(
|
| 213 |
+
text: Optional[str],
|
| 214 |
+
*,
|
| 215 |
+
min_length: int = 1,
|
| 216 |
+
) -> list[tuple[int, str, int]]:
|
| 217 |
+
"""Retourne les numéraux romains valides dans ``text``.
|
| 218 |
+
|
| 219 |
+
Forme : ``[(start_index, numeral_string, integer_value), ...]``
|
| 220 |
+
triée par index croissant.
|
| 221 |
+
|
| 222 |
+
Parameters
|
| 223 |
+
----------
|
| 224 |
+
text:
|
| 225 |
+
Texte à analyser.
|
| 226 |
+
min_length:
|
| 227 |
+
Longueur minimale d'un numéral retenu. Par défaut ``1``.
|
| 228 |
+
Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
|
| 229 |
+
pronom, ``M`` initiale).
|
| 230 |
+
|
| 231 |
+
Faux positifs connus
|
| 232 |
+
--------------------
|
| 233 |
+
- ``I`` (pronom anglais), ``M`` ou ``D`` en initiale d'une
|
| 234 |
+
personne ne peuvent pas être distingués sans NER. Le chercheur
|
| 235 |
+
qui s'inquiète de ces faux positifs peut passer
|
| 236 |
+
``min_length=2``.
|
| 237 |
+
"""
|
| 238 |
+
if not text:
|
| 239 |
+
return []
|
| 240 |
+
found: list[tuple[int, str, int]] = []
|
| 241 |
+
for match in _ROMAN_RE.finditer(text):
|
| 242 |
+
s = match.group(0)
|
| 243 |
+
if len(s) < min_length:
|
| 244 |
+
continue
|
| 245 |
+
value = roman_to_int(s)
|
| 246 |
+
if value is None:
|
| 247 |
+
continue
|
| 248 |
+
found.append((match.start(), s, value))
|
| 249 |
+
return found
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 253 |
+
# Classification de la restitution dans l'hypothèse
|
| 254 |
+
# ──────────────────────────────────────────��───────────────────────────────
|
| 255 |
+
|
| 256 |
+
# Statuts possibles, dans l'ordre de priorité (un numéral est
|
| 257 |
+
# classé selon le premier statut qui s'applique).
|
| 258 |
+
|
| 259 |
+
STATUS_STRICT_PRESERVED = "strict_preserved"
|
| 260 |
+
STATUS_CASE_CHANGED = "case_changed"
|
| 261 |
+
STATUS_J_DROPPED = "j_dropped"
|
| 262 |
+
STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
|
| 263 |
+
STATUS_LOST = "lost"
|
| 264 |
+
|
| 265 |
+
ALL_STATUSES = (
|
| 266 |
+
STATUS_STRICT_PRESERVED,
|
| 267 |
+
STATUS_CASE_CHANGED,
|
| 268 |
+
STATUS_J_DROPPED,
|
| 269 |
+
STATUS_CONVERTED_TO_ARABIC,
|
| 270 |
+
STATUS_LOST,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Statuts qui indiquent une préservation de la valeur (par opposition
|
| 274 |
+
# à la perte).
|
| 275 |
+
VALUE_PRESERVING_STATUSES = frozenset({
|
| 276 |
+
STATUS_STRICT_PRESERVED,
|
| 277 |
+
STATUS_CASE_CHANGED,
|
| 278 |
+
STATUS_J_DROPPED,
|
| 279 |
+
STATUS_CONVERTED_TO_ARABIC,
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
|
| 284 |
+
"""Classifie comment ``numeral`` (de valeur ``value``) est
|
| 285 |
+
restitué dans ``hyp`` selon les 5 statuts définis."""
|
| 286 |
+
# 1. Forme stricte présente
|
| 287 |
+
if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
|
| 288 |
+
return STATUS_STRICT_PRESERVED
|
| 289 |
+
# 2. Variante de casse seule
|
| 290 |
+
swapped = numeral.swapcase()
|
| 291 |
+
if swapped != numeral and re.search(
|
| 292 |
+
r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
|
| 293 |
+
):
|
| 294 |
+
return STATUS_CASE_CHANGED
|
| 295 |
+
# 3. ``j`` final remplacé par ``i`` (ou inverse)
|
| 296 |
+
if numeral.lower().endswith("j"):
|
| 297 |
+
no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
|
| 298 |
+
elif numeral.lower().endswith("i"):
|
| 299 |
+
no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
|
| 300 |
+
else:
|
| 301 |
+
no_j = numeral
|
| 302 |
+
if no_j != numeral and re.search(
|
| 303 |
+
r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
|
| 304 |
+
):
|
| 305 |
+
return STATUS_J_DROPPED
|
| 306 |
+
# Variante de casse + j-flip combinés
|
| 307 |
+
no_j_swapped = no_j.swapcase()
|
| 308 |
+
if no_j_swapped != numeral and re.search(
|
| 309 |
+
r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
|
| 310 |
+
):
|
| 311 |
+
return STATUS_J_DROPPED
|
| 312 |
+
# 4. Conversion en chiffres arabes
|
| 313 |
+
if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
|
| 314 |
+
return STATUS_CONVERTED_TO_ARABIC
|
| 315 |
+
# 5. Perdu
|
| 316 |
+
return STATUS_LOST
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 320 |
+
# Calcul de la métrique
|
| 321 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def compute_roman_numeral_metrics(
|
| 325 |
+
reference: Optional[str],
|
| 326 |
+
hypothesis: Optional[str],
|
| 327 |
+
*,
|
| 328 |
+
min_length: int = 1,
|
| 329 |
+
) -> dict:
|
| 330 |
+
"""Calcule la préservation des numéraux romains.
|
| 331 |
+
|
| 332 |
+
Pour chaque numéral romain dans la GT, on classifie sa
|
| 333 |
+
restitution dans l'hypothèse selon l'un des 5 statuts (forme
|
| 334 |
+
stricte / casse modifiée / j supprimé / conversion arabe / perdu).
|
| 335 |
+
|
| 336 |
+
Returns
|
| 337 |
+
-------
|
| 338 |
+
dict
|
| 339 |
+
``{
|
| 340 |
+
"n_numerals_reference": int,
|
| 341 |
+
"n_strict_preserved": int,
|
| 342 |
+
"n_value_preserved": int, # tous statuts sauf LOST
|
| 343 |
+
"global_strict_score": float,
|
| 344 |
+
"global_value_score": float,
|
| 345 |
+
"per_status": {status: count for status in ALL_STATUSES},
|
| 346 |
+
"per_numeral": [
|
| 347 |
+
{"index", "numeral", "value", "status"}
|
| 348 |
+
],
|
| 349 |
+
"lost_numerals": [
|
| 350 |
+
{"index", "numeral", "value"}
|
| 351 |
+
],
|
| 352 |
+
}``
|
| 353 |
+
|
| 354 |
+
Cas dégénérés
|
| 355 |
+
-------------
|
| 356 |
+
- GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
|
| 357 |
+
``per_status`` initialisé à 0 sur tous les statuts.
|
| 358 |
+
- GT avec numéraux + hyp vide → tous classés ``lost``,
|
| 359 |
+
strict_score = value_score = 0.0.
|
| 360 |
+
"""
|
| 361 |
+
ref = reference or ""
|
| 362 |
+
hyp = hypothesis or ""
|
| 363 |
+
|
| 364 |
+
detected = detect_roman_numerals(ref, min_length=min_length)
|
| 365 |
+
n_total = len(detected)
|
| 366 |
+
per_status_init = {status: 0 for status in ALL_STATUSES}
|
| 367 |
+
|
| 368 |
+
if n_total == 0:
|
| 369 |
+
return {
|
| 370 |
+
"n_numerals_reference": 0,
|
| 371 |
+
"n_strict_preserved": 0,
|
| 372 |
+
"n_value_preserved": 0,
|
| 373 |
+
"global_strict_score": 0.0,
|
| 374 |
+
"global_value_score": 0.0,
|
| 375 |
+
"per_status": per_status_init,
|
| 376 |
+
"per_numeral": [],
|
| 377 |
+
"lost_numerals": [],
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
per_status: dict[str, int] = dict(per_status_init)
|
| 381 |
+
per_numeral: list[dict] = []
|
| 382 |
+
lost: list[dict] = []
|
| 383 |
+
for index, numeral, value in detected:
|
| 384 |
+
status = _classify_restitution(numeral, value, hyp)
|
| 385 |
+
per_status[status] = per_status.get(status, 0) + 1
|
| 386 |
+
per_numeral.append({
|
| 387 |
+
"index": index,
|
| 388 |
+
"numeral": numeral,
|
| 389 |
+
"value": value,
|
| 390 |
+
"status": status,
|
| 391 |
+
})
|
| 392 |
+
if status == STATUS_LOST:
|
| 393 |
+
lost.append({"index": index, "numeral": numeral, "value": value})
|
| 394 |
+
|
| 395 |
+
n_strict = per_status[STATUS_STRICT_PRESERVED]
|
| 396 |
+
n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
|
| 397 |
+
|
| 398 |
+
return {
|
| 399 |
+
"n_numerals_reference": n_total,
|
| 400 |
+
"n_strict_preserved": n_strict,
|
| 401 |
+
"n_value_preserved": n_value,
|
| 402 |
+
"global_strict_score": n_strict / n_total,
|
| 403 |
+
"global_value_score": n_value / n_total,
|
| 404 |
+
"per_status": per_status,
|
| 405 |
+
"per_numeral": per_numeral,
|
| 406 |
+
"lost_numerals": lost,
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def roman_numeral_strict_score(
|
| 411 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 412 |
+
) -> float:
|
| 413 |
+
"""Raccourci : taux global de préservation **stricte** des
|
| 414 |
+
numéraux romains ∈ [0, 1]."""
|
| 415 |
+
return compute_roman_numeral_metrics(
|
| 416 |
+
reference, hypothesis,
|
| 417 |
+
)["global_strict_score"]
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def roman_numeral_value_score(
|
| 421 |
+
reference: Optional[str], hypothesis: Optional[str],
|
| 422 |
+
) -> float:
|
| 423 |
+
"""Raccourci : taux global de préservation de la **valeur** des
|
| 424 |
+
numéraux romains (toute forme confondue : strict, case_changed,
|
| 425 |
+
j_dropped, arabe) ∈ [0, 1]."""
|
| 426 |
+
return compute_roman_numeral_metrics(
|
| 427 |
+
reference, hypothesis,
|
| 428 |
+
)["global_value_score"]
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 432 |
+
# Enregistrement dans le registre typé (Sprint 34)
|
| 433 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
@register_metric(
|
| 437 |
+
name="roman_numeral_strict_score",
|
| 438 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 439 |
+
description=(
|
| 440 |
+
"Taux de préservation stricte des numéraux romains "
|
| 441 |
+
"(forme exacte gardée : casse, j médiéval final). "
|
| 442 |
+
"Métrique transversale aux périodes médiévale, imprimé "
|
| 443 |
+
"ancien et moderne."
|
| 444 |
+
),
|
| 445 |
+
higher_is_better=True,
|
| 446 |
+
tags={"text", "roman_numerals", "philology"},
|
| 447 |
+
)
|
| 448 |
+
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 449 |
+
return roman_numeral_strict_score(reference, hypothesis)
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@register_metric(
|
| 453 |
+
name="roman_numeral_value_score",
|
| 454 |
+
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 455 |
+
description=(
|
| 456 |
+
"Taux de préservation de la valeur numérique des numéraux "
|
| 457 |
+
"romains, indépendamment de la forme (strict, casse "
|
| 458 |
+
"changée, j supprimé, conversion en chiffres arabes). "
|
| 459 |
+
"Le breakdown per_status permet au chercheur de juger la "
|
| 460 |
+
"convention adoptée."
|
| 461 |
+
),
|
| 462 |
+
higher_is_better=True,
|
| 463 |
+
tags={"text", "roman_numerals", "philology"},
|
| 464 |
+
)
|
| 465 |
+
def _registered_value(reference: str, hypothesis: str) -> float:
|
| 466 |
+
return roman_numeral_value_score(reference, hypothesis)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
__all__ = [
|
| 470 |
+
"ROMAN_VALUES",
|
| 471 |
+
"ALL_STATUSES",
|
| 472 |
+
"STATUS_STRICT_PRESERVED",
|
| 473 |
+
"STATUS_CASE_CHANGED",
|
| 474 |
+
"STATUS_J_DROPPED",
|
| 475 |
+
"STATUS_CONVERTED_TO_ARABIC",
|
| 476 |
+
"STATUS_LOST",
|
| 477 |
+
"VALUE_PRESERVING_STATUSES",
|
| 478 |
+
"compute_roman_numeral_metrics",
|
| 479 |
+
"detect_roman_numerals",
|
| 480 |
+
"int_to_roman",
|
| 481 |
+
"roman_numeral_strict_score",
|
| 482 |
+
"roman_numeral_value_score",
|
| 483 |
+
"roman_to_int",
|
| 484 |
+
]
|
|
@@ -0,0 +1,622 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Banc d'essai de pipelines composées — Sprint 63 (axe B).
|
| 2 |
+
|
| 3 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 4 |
+
``picarones.core.pipeline`` vers ``picarones.evaluation.pipeline``.
|
| 5 |
+
Le chemin legacy reste disponible via un shim avec
|
| 6 |
+
``DeprecationWarning`` ; suppression prévue en 2.0.
|
| 7 |
+
|
| 8 |
+
Coexistence avec ``picarones.pipeline.executor``
|
| 9 |
+
------------------------------------------------
|
| 10 |
+
Le présent module porte le ``PipelineRunner`` historique
|
| 11 |
+
(Sprint 63), riche en behavior, qui orchestre l'exécution
|
| 12 |
+
mono-document. Le module canonique
|
| 13 |
+
``picarones.pipeline.executor`` (Sprint S6) propose un design
|
| 14 |
+
différent (instance-based, immutable specs). Les deux
|
| 15 |
+
cohabitent volontairement ; un convertisseur explicite viendra
|
| 16 |
+
quand un caller institutionnel l'exigera.
|
| 17 |
+
|
| 18 |
+
Sprint 63 — Étape 4 / axe B du plan d'évolution 2026 : démarrage du
|
| 19 |
+
banc d'essai de pipelines.
|
| 20 |
+
|
| 21 |
+
Philosophie
|
| 22 |
+
-----------
|
| 23 |
+
Picarones est un **banc d'essai**, pas un atelier de production.
|
| 24 |
+
Cette infrastructure permet d'**évaluer des pipelines composées de
|
| 25 |
+
modules tiers** que l'utilisateur amène — par exemple :
|
| 26 |
+
|
| 27 |
+
- ``[OCR(image→texte)] → [reconstructeur ALTO tiers(texte→ALTO)]``
|
| 28 |
+
- ``[VLM(image→ALTO)] → [post-processing tiers(ALTO→ALTO)]``
|
| 29 |
+
- ``[OCR(image→texte)] → [LLM correcteur(texte→texte)]``
|
| 30 |
+
|
| 31 |
+
Picarones **ne fournit aucun module métier** (pas de
|
| 32 |
+
reconstructeur ALTO, pas de correcteur, pas de re-segmenteur).
|
| 33 |
+
L'utilisateur branche ses propres ``BaseModule`` (Sprint 33), le
|
| 34 |
+
runner orchestre l'exécution séquentielle, valide les types aux
|
| 35 |
+
jonctions et **évalue automatiquement** chaque artefact produit
|
| 36 |
+
contre la GT du même niveau (Sprint 32) en sélectionnant les
|
| 37 |
+
métriques pertinentes du registre typé (Sprint 34).
|
| 38 |
+
|
| 39 |
+
Périmètre Sprint 63
|
| 40 |
+
-------------------
|
| 41 |
+
Inclus :
|
| 42 |
+
|
| 43 |
+
- Spécification déclarative d'une pipeline séquentielle.
|
| 44 |
+
- Exécution sur un seul document avec passage typé d'artefacts.
|
| 45 |
+
- Validation des types aux jonctions inter-modules.
|
| 46 |
+
- Évaluation automatique aux jonctions GT-vs-sortie pour chaque
|
| 47 |
+
niveau de GT disponible sur le document.
|
| 48 |
+
- Mesure du temps par étape.
|
| 49 |
+
- Capture gracieuse des erreurs (un module qui lève n'arrête pas
|
| 50 |
+
les étapes suivantes — leur entrée manquante est rapportée
|
| 51 |
+
comme erreur explicite).
|
| 52 |
+
|
| 53 |
+
Reporté à des sprints dédiés :
|
| 54 |
+
|
| 55 |
+
- DAG branchant non séquentiel (1 → {2, 3} → 4) — Sprint 64+.
|
| 56 |
+
- Orchestration corpus-wide + agrégation par pipeline — Sprint 65+.
|
| 57 |
+
- Vue HTML dédiée aux pipelines composées — Sprint 66+.
|
| 58 |
+
- Cache d'artefacts intermédiaires — non prévu.
|
| 59 |
+
- Parallélisation inter-étapes — non prévue (les modules
|
| 60 |
+
``execution_mode`` sont déjà respectés par le runner historique
|
| 61 |
+
pour le bench OCR mono-étage).
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
from __future__ import annotations
|
| 65 |
+
|
| 66 |
+
import logging
|
| 67 |
+
import time
|
| 68 |
+
from dataclasses import dataclass, field
|
| 69 |
+
from typing import Any, Optional
|
| 70 |
+
|
| 71 |
+
from picarones.evaluation.corpus import Document, GTLevel
|
| 72 |
+
from picarones.evaluation.metric_registry import compute_at_junction
|
| 73 |
+
from picarones.domain.artifacts import ArtifactType
|
| 74 |
+
from picarones.domain.module_protocol import BaseModule
|
| 75 |
+
|
| 76 |
+
# Sprint A3 (renforce la règle Cercle 1 → Cercle 1 uniquement) — la
|
| 77 |
+
# cérémonie d'eager-load des métriques typées (Sprint 34) qui vivait
|
| 78 |
+
# ici a été déplacée dans ``picarones/measurements/__init__.py``. Tout
|
| 79 |
+
# consommateur de ``compute_at_junction`` (typiquement la classe
|
| 80 |
+
# ``PipelineRunner`` ci-dessous) doit avoir importé
|
| 81 |
+
# ``picarones.measurements`` au moins une fois — c'est le cas dans
|
| 82 |
+
# l'API publique via ``picarones.__init__`` qui déclenche le trigger.
|
| 83 |
+
|
| 84 |
+
logger = logging.getLogger(__name__)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 88 |
+
# Conversion ArtifactType <-> GTLevel
|
| 89 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
#: Map ``ArtifactType`` canonique → ``GTLevel`` legacy. Phase 4-bis :
|
| 93 |
+
#: ``ArtifactType`` a été migré vers ``domain/artifacts.py`` qui
|
| 94 |
+
#: distingue ``RAW_TEXT``/``CORRECTED_TEXT`` (vs ``TEXT`` legacy) et
|
| 95 |
+
#: ``ALTO_XML``/``PAGE_XML`` (vs ``ALTO``/``PAGE`` legacy). Les
|
| 96 |
+
#: valeurs canoniques ne matchent donc plus celles de ``GTLevel``.
|
| 97 |
+
#: Ce mapping explicite fait le pont — sera retiré en 2.0 quand
|
| 98 |
+
#: ``GTLevel`` aura aussi été retiré au profit de la projection
|
| 99 |
+
#: ``ArtifactType → niveau d'évaluation`` du rewrite.
|
| 100 |
+
_ARTIFACT_TO_GT_LEVEL: dict[ArtifactType, GTLevel] = {
|
| 101 |
+
ArtifactType.RAW_TEXT: GTLevel.TEXT,
|
| 102 |
+
ArtifactType.CORRECTED_TEXT: GTLevel.TEXT,
|
| 103 |
+
ArtifactType.ALTO_XML: GTLevel.ALTO,
|
| 104 |
+
ArtifactType.PAGE_XML: GTLevel.PAGE,
|
| 105 |
+
ArtifactType.ENTITIES: GTLevel.ENTITIES,
|
| 106 |
+
ArtifactType.READING_ORDER: GTLevel.READING_ORDER,
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _artifact_type_to_gt_level(at: ArtifactType) -> Optional[GTLevel]:
|
| 111 |
+
"""Retourne le ``GTLevel`` correspondant à un ``ArtifactType``.
|
| 112 |
+
|
| 113 |
+
``IMAGE`` n'a pas de correspondance GT (on n'évalue pas une
|
| 114 |
+
image en sortie d'un module — c'est typiquement une entrée).
|
| 115 |
+
Les types ``CONFIDENCES``, ``ALIGNMENT``, ``CANONICAL_DOCUMENT``
|
| 116 |
+
n'ont pas non plus de niveau de GT direct dans le legacy.
|
| 117 |
+
"""
|
| 118 |
+
return _ARTIFACT_TO_GT_LEVEL.get(at)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 122 |
+
# PipelineStep + PipelineSpec
|
| 123 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass
|
| 127 |
+
class PipelineStep:
|
| 128 |
+
"""Une étape dans une pipeline composée.
|
| 129 |
+
|
| 130 |
+
L'étape porte un nom lisible (utile pour le rapport et le
|
| 131 |
+
diagnostic) et une instance de ``BaseModule`` fournie par
|
| 132 |
+
l'utilisateur. Les types d'entrée et de sortie ne sont pas
|
| 133 |
+
redéclarés ici : ils sont lus depuis le module lui-même
|
| 134 |
+
(``module.input_types`` / ``module.output_types``).
|
| 135 |
+
|
| 136 |
+
Sprint 66 — DAG branchant
|
| 137 |
+
-------------------------
|
| 138 |
+
``inputs_from`` permet de désigner explicitement, pour chaque
|
| 139 |
+
type d'entrée, l'étape source dont on veut consommer l'artefact.
|
| 140 |
+
Utile quand plusieurs étapes antérieures produisent le même
|
| 141 |
+
type et qu'on veut éviter l'écrasement implicite (par exemple
|
| 142 |
+
deux correcteurs LLM en parallèle qui partent du même OCR).
|
| 143 |
+
|
| 144 |
+
- ``inputs_from = {}`` (défaut) : pour chaque type d'entrée,
|
| 145 |
+
le runner prend la version **la plus récente** disponible
|
| 146 |
+
dans le bag (comportement Sprint 63, rétrocompat stricte).
|
| 147 |
+
- ``inputs_from = {ArtifactType.TEXT: "ocr"}`` : exige la
|
| 148 |
+
version du ``TEXT`` produite par l'étape nommée ``"ocr"``.
|
| 149 |
+
Si cette étape n'existe pas ou n'a pas produit ce type,
|
| 150 |
+
``PipelineSpec.validate`` remonte un problème explicite et
|
| 151 |
+
le runner remonte une erreur d'entrée manquante.
|
| 152 |
+
|
| 153 |
+
La chaîne spéciale ``"__initial__"`` désigne les artefacts
|
| 154 |
+
fournis dans ``initial_inputs`` (par exemple ``IMAGE``).
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
name: str
|
| 158 |
+
module: BaseModule
|
| 159 |
+
inputs_from: dict[ArtifactType, str] = field(default_factory=dict)
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def input_types(self) -> tuple[ArtifactType, ...]:
|
| 163 |
+
return tuple(self.module.input_types)
|
| 164 |
+
|
| 165 |
+
@property
|
| 166 |
+
def output_types(self) -> tuple[ArtifactType, ...]:
|
| 167 |
+
return tuple(self.module.output_types)
|
| 168 |
+
|
| 169 |
+
def __repr__(self) -> str:
|
| 170 |
+
ins = ",".join(t.value for t in self.input_types) or "·"
|
| 171 |
+
outs = ",".join(t.value for t in self.output_types) or "·"
|
| 172 |
+
if self.inputs_from:
|
| 173 |
+
refs = ",".join(
|
| 174 |
+
f"{t.value}@{src}" for t, src in self.inputs_from.items()
|
| 175 |
+
)
|
| 176 |
+
return f"PipelineStep({self.name}: [{refs}] → {outs})"
|
| 177 |
+
return f"PipelineStep({self.name}: {ins} → {outs})"
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@dataclass
|
| 181 |
+
class PipelineSpec:
|
| 182 |
+
"""DAG séquentiel de ``PipelineStep``.
|
| 183 |
+
|
| 184 |
+
Sprint 63 — séquentiel uniquement : l'étape ``i+1`` consomme
|
| 185 |
+
les artefacts produits par l'étape ``i`` (et tous les artefacts
|
| 186 |
+
initiaux fournis au runner, par exemple l'image source).
|
| 187 |
+
|
| 188 |
+
Le DAG branchant arrive dans un sprint dédié.
|
| 189 |
+
"""
|
| 190 |
+
|
| 191 |
+
name: str
|
| 192 |
+
steps: list[PipelineStep] = field(default_factory=list)
|
| 193 |
+
|
| 194 |
+
def validate(self, initial_inputs: tuple[ArtifactType, ...]) -> list[str]:
|
| 195 |
+
"""Vérifie que les types s'enchaînent et retourne la liste
|
| 196 |
+
des problèmes détectés (vide si la pipeline est valide).
|
| 197 |
+
|
| 198 |
+
Une pipeline est valide si, pour chaque étape, tous les
|
| 199 |
+
``input_types`` sont disponibles : soit dans les
|
| 200 |
+
``initial_inputs`` (typiquement ``IMAGE``), soit produits
|
| 201 |
+
par une étape antérieure.
|
| 202 |
+
|
| 203 |
+
Sprint 66 — validation des références ``inputs_from`` :
|
| 204 |
+
si une étape déclare ``inputs_from[type] = "foo"``,
|
| 205 |
+
l'étape ``foo`` doit exister parmi les étapes antérieures
|
| 206 |
+
et avoir ce type dans ses ``output_types``. La chaîne
|
| 207 |
+
spéciale ``"__initial__"`` désigne les entrées initiales.
|
| 208 |
+
"""
|
| 209 |
+
problems: list[str] = []
|
| 210 |
+
if not self.steps:
|
| 211 |
+
problems.append("pipeline vide : au moins une étape est requise")
|
| 212 |
+
return problems
|
| 213 |
+
# Map type → set des steps qui ont produit ce type
|
| 214 |
+
# ("__initial__" pour les entrées initiales) — utilisé pour
|
| 215 |
+
# valider les références ``inputs_from``.
|
| 216 |
+
producers: dict[ArtifactType, set[str]] = {
|
| 217 |
+
t: {"__initial__"} for t in initial_inputs
|
| 218 |
+
}
|
| 219 |
+
# Map step_name → set des types produits, pour la validation
|
| 220 |
+
# des références.
|
| 221 |
+
step_outputs: dict[str, set[ArtifactType]] = {
|
| 222 |
+
"__initial__": set(initial_inputs),
|
| 223 |
+
}
|
| 224 |
+
# Set des types disponibles à un instant t (latest seulement).
|
| 225 |
+
available: set[ArtifactType] = set(initial_inputs)
|
| 226 |
+
|
| 227 |
+
for i, step in enumerate(self.steps):
|
| 228 |
+
# 1. Toutes les entrées doivent être disponibles
|
| 229 |
+
missing = [t for t in step.input_types if t not in available]
|
| 230 |
+
if missing:
|
| 231 |
+
miss_str = ",".join(t.value for t in missing)
|
| 232 |
+
problems.append(
|
| 233 |
+
f"étape {i} ({step.name}) demande {miss_str} "
|
| 234 |
+
f"qui n'est ni dans les entrées initiales "
|
| 235 |
+
f"ni produit par une étape antérieure"
|
| 236 |
+
)
|
| 237 |
+
# 2. Vérification des références ``inputs_from``
|
| 238 |
+
for ref_type, ref_step in step.inputs_from.items():
|
| 239 |
+
if ref_type not in step.input_types:
|
| 240 |
+
problems.append(
|
| 241 |
+
f"étape {i} ({step.name}) déclare "
|
| 242 |
+
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 243 |
+
f"mais le module ne consomme pas ce type"
|
| 244 |
+
)
|
| 245 |
+
continue
|
| 246 |
+
if ref_step not in step_outputs:
|
| 247 |
+
problems.append(
|
| 248 |
+
f"étape {i} ({step.name}) référence "
|
| 249 |
+
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 250 |
+
f"qui n'est pas une étape antérieure connue"
|
| 251 |
+
)
|
| 252 |
+
continue
|
| 253 |
+
if ref_type not in step_outputs[ref_step]:
|
| 254 |
+
problems.append(
|
| 255 |
+
f"étape {i} ({step.name}) référence "
|
| 256 |
+
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 257 |
+
f"mais cette étape ne produit pas ce type"
|
| 258 |
+
)
|
| 259 |
+
# 3. Mise à jour pour les étapes suivantes
|
| 260 |
+
available.update(step.output_types)
|
| 261 |
+
step_outputs[step.name] = set(step.output_types)
|
| 262 |
+
for out_type in step.output_types:
|
| 263 |
+
producers.setdefault(out_type, set()).add(step.name)
|
| 264 |
+
return problems
|
| 265 |
+
|
| 266 |
+
def is_valid(self, initial_inputs: tuple[ArtifactType, ...]) -> bool:
|
| 267 |
+
return not self.validate(initial_inputs)
|
| 268 |
+
|
| 269 |
+
def __repr__(self) -> str:
|
| 270 |
+
chain = " → ".join(str(s) for s in self.steps)
|
| 271 |
+
return f"PipelineSpec({self.name}: {chain})"
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 275 |
+
# StepResult + PipelineResult
|
| 276 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@dataclass
|
| 280 |
+
class StepResult:
|
| 281 |
+
"""Résultat de l'exécution d'une étape sur un document.
|
| 282 |
+
|
| 283 |
+
Champs
|
| 284 |
+
------
|
| 285 |
+
step_name:
|
| 286 |
+
Nom de l'étape (cf. ``PipelineStep.name``).
|
| 287 |
+
duration_seconds:
|
| 288 |
+
Temps d'exécution de ``module.process`` mesuré en wall-clock.
|
| 289 |
+
output_types:
|
| 290 |
+
Types effectivement présents dans la sortie (peut être un
|
| 291 |
+
sous-ensemble de ``module.output_types`` si le module a
|
| 292 |
+
omis un type — cas reporté ici comme info pour diagnostic).
|
| 293 |
+
junction_metrics:
|
| 294 |
+
Pour chaque type produit qui correspond à un ``GTLevel``
|
| 295 |
+
dont le document porte une GT : dictionnaire ``{type: dict
|
| 296 |
+
métriques}`` retourné par ``compute_at_junction``.
|
| 297 |
+
error:
|
| 298 |
+
``None`` si l'étape s'est bien déroulée ; sinon message
|
| 299 |
+
d'erreur (le module a levé, l'entrée est manquante, ou la
|
| 300 |
+
validation des types a échoué).
|
| 301 |
+
"""
|
| 302 |
+
|
| 303 |
+
step_name: str
|
| 304 |
+
duration_seconds: float
|
| 305 |
+
output_types: tuple[ArtifactType, ...]
|
| 306 |
+
junction_metrics: dict[str, dict[str, Any]] = field(default_factory=dict)
|
| 307 |
+
"""Map ``{artifact_type_value: {metric_name: value}}``.
|
| 308 |
+
|
| 309 |
+
La clé est la valeur string du ``ArtifactType`` (ex. ``"text"``,
|
| 310 |
+
``"alto"``) et non l'enum lui-même, pour faciliter la
|
| 311 |
+
sérialisation JSON.
|
| 312 |
+
"""
|
| 313 |
+
error: Optional[str] = None
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@dataclass
|
| 317 |
+
class PipelineResult:
|
| 318 |
+
"""Résultat complet d'une exécution de pipeline sur un document.
|
| 319 |
+
|
| 320 |
+
On capture la durée totale, la durée par étape et les
|
| 321 |
+
métriques aux jonctions pour chaque artefact produit qui a une
|
| 322 |
+
GT correspondante.
|
| 323 |
+
"""
|
| 324 |
+
|
| 325 |
+
pipeline_name: str
|
| 326 |
+
doc_id: str
|
| 327 |
+
steps: list[StepResult] = field(default_factory=list)
|
| 328 |
+
total_duration_seconds: float = 0.0
|
| 329 |
+
error: Optional[str] = None
|
| 330 |
+
"""Erreur fatale au niveau pipeline (ex. validation des types
|
| 331 |
+
en amont avant la première étape). ``None`` n'implique pas
|
| 332 |
+
qu'aucune étape n'a échoué — voir ``StepResult.error`` pour le
|
| 333 |
+
détail par étape."""
|
| 334 |
+
|
| 335 |
+
@property
|
| 336 |
+
def succeeded(self) -> bool:
|
| 337 |
+
"""Vrai si la pipeline s'est exécutée jusqu'au bout sans
|
| 338 |
+
qu'aucune étape ne lève d'erreur."""
|
| 339 |
+
if self.error is not None:
|
| 340 |
+
return False
|
| 341 |
+
return all(s.error is None for s in self.steps)
|
| 342 |
+
|
| 343 |
+
@property
|
| 344 |
+
def failing_steps(self) -> list[str]:
|
| 345 |
+
"""Noms des étapes ayant levé une erreur."""
|
| 346 |
+
return [s.step_name for s in self.steps if s.error is not None]
|
| 347 |
+
|
| 348 |
+
def junction_metrics_for(
|
| 349 |
+
self, artifact_type: ArtifactType,
|
| 350 |
+
) -> Optional[dict[str, Any]]:
|
| 351 |
+
"""Retourne les métriques de la **dernière** étape qui a
|
| 352 |
+
produit ``artifact_type``, ou ``None`` si aucune étape ne
|
| 353 |
+
l'a produit avec succès.
|
| 354 |
+
|
| 355 |
+
Utile pour comparer plusieurs pipelines qui produisent in
|
| 356 |
+
fine le même type (ex. deux DAG aboutissant à du texte
|
| 357 |
+
corrigé).
|
| 358 |
+
"""
|
| 359 |
+
from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
|
| 360 |
+
legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
|
| 361 |
+
for step in reversed(self.steps):
|
| 362 |
+
if step.error is not None:
|
| 363 |
+
continue
|
| 364 |
+
metrics = step.junction_metrics.get(artifact_type.value)
|
| 365 |
+
if metrics is None and legacy_alias is not None:
|
| 366 |
+
# Phase 4-bis : un caller legacy peut avoir construit
|
| 367 |
+
# le dict avec la clé pré-rewrite ("text" au lieu de
|
| 368 |
+
# "raw_text"). expand_legacy_keys synchronise les deux
|
| 369 |
+
# côtés sur les sites d'écriture du runner, mais des
|
| 370 |
+
# StepResult construits à la main par les tests ou par
|
| 371 |
+
# un caller externe peuvent encore avoir une seule
|
| 372 |
+
# clé — on tolère.
|
| 373 |
+
metrics = step.junction_metrics.get(legacy_alias)
|
| 374 |
+
if metrics is not None:
|
| 375 |
+
return metrics
|
| 376 |
+
return None
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 380 |
+
# Exécuteur
|
| 381 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
class PipelineRunner:
|
| 385 |
+
"""Exécute une ``PipelineSpec`` sur un document.
|
| 386 |
+
|
| 387 |
+
Sprint 63 — un seul document à la fois. L'orchestration
|
| 388 |
+
corpus-wide et l'agrégation par pipeline sont reportées à un
|
| 389 |
+
sprint dédié.
|
| 390 |
+
|
| 391 |
+
Usage typique
|
| 392 |
+
-------------
|
| 393 |
+
|
| 394 |
+
>>> spec = PipelineSpec(
|
| 395 |
+
... name="ocr_then_rewrite",
|
| 396 |
+
... steps=[
|
| 397 |
+
... PipelineStep("ocr", my_ocr_module),
|
| 398 |
+
... PipelineStep("rewrite", my_llm_rewriter),
|
| 399 |
+
... ],
|
| 400 |
+
... )
|
| 401 |
+
>>> runner = PipelineRunner()
|
| 402 |
+
>>> result = runner.run(spec, document, {ArtifactType.IMAGE: "/path/img.png"})
|
| 403 |
+
>>> result.succeeded
|
| 404 |
+
True
|
| 405 |
+
>>> result.junction_metrics_for(ArtifactType.TEXT)
|
| 406 |
+
{'cer': 0.05, 'wer': 0.12, ...}
|
| 407 |
+
"""
|
| 408 |
+
|
| 409 |
+
@staticmethod
|
| 410 |
+
def run(
|
| 411 |
+
spec: PipelineSpec,
|
| 412 |
+
document: Document,
|
| 413 |
+
initial_inputs: dict[ArtifactType, Any],
|
| 414 |
+
) -> PipelineResult:
|
| 415 |
+
"""Exécute ``spec`` sur ``document`` à partir de
|
| 416 |
+
``initial_inputs``.
|
| 417 |
+
|
| 418 |
+
Parameters
|
| 419 |
+
----------
|
| 420 |
+
spec:
|
| 421 |
+
Spécification de la pipeline.
|
| 422 |
+
document:
|
| 423 |
+
Document du corpus, porteur de zéro ou plusieurs niveaux
|
| 424 |
+
de GT (Sprint 32).
|
| 425 |
+
initial_inputs:
|
| 426 |
+
Artefacts initiaux par type — typiquement
|
| 427 |
+
``{ArtifactType.IMAGE: "/path/img.png"}`` pour une
|
| 428 |
+
pipeline qui démarre par un OCR.
|
| 429 |
+
|
| 430 |
+
Returns
|
| 431 |
+
-------
|
| 432 |
+
PipelineResult
|
| 433 |
+
Résultat complet : durée totale, résultat par étape,
|
| 434 |
+
métriques aux jonctions évaluées contre la GT.
|
| 435 |
+
"""
|
| 436 |
+
result = PipelineResult(
|
| 437 |
+
pipeline_name=spec.name, doc_id=document.doc_id,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# Validation amont : si la pipeline est statiquement
|
| 441 |
+
# invalide, on n'exécute aucune étape.
|
| 442 |
+
problems = spec.validate(tuple(initial_inputs.keys()))
|
| 443 |
+
if problems:
|
| 444 |
+
result.error = " ; ".join(problems)
|
| 445 |
+
return result
|
| 446 |
+
|
| 447 |
+
# Sprint 66 — bag versionné : ``versioned[(type, src_step)]``
|
| 448 |
+
# contient l'artefact produit par ``src_step`` pour ``type``.
|
| 449 |
+
# ``src_step`` vaut ``"__initial__"`` pour les entrées
|
| 450 |
+
# initiales fournies par l'utilisateur. ``latest[type]``
|
| 451 |
+
# désigne le nom de l'étape qui a produit la version la plus
|
| 452 |
+
# récente du type — utilisé en l'absence d'``inputs_from``
|
| 453 |
+
# explicite (rétrocompat Sprint 63).
|
| 454 |
+
versioned: dict[tuple[ArtifactType, str], Any] = {
|
| 455 |
+
(t, "__initial__"): v for t, v in initial_inputs.items()
|
| 456 |
+
}
|
| 457 |
+
latest: dict[ArtifactType, str] = {
|
| 458 |
+
t: "__initial__" for t in initial_inputs
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
pipeline_t0 = time.monotonic()
|
| 462 |
+
for step in spec.steps:
|
| 463 |
+
step_result = PipelineRunner._run_step(
|
| 464 |
+
step, versioned, latest, document,
|
| 465 |
+
)
|
| 466 |
+
result.steps.append(step_result)
|
| 467 |
+
result.total_duration_seconds = time.monotonic() - pipeline_t0
|
| 468 |
+
return result
|
| 469 |
+
|
| 470 |
+
@staticmethod
|
| 471 |
+
def _run_step(
|
| 472 |
+
step: PipelineStep,
|
| 473 |
+
versioned: dict[tuple[ArtifactType, str], Any],
|
| 474 |
+
latest: dict[ArtifactType, str],
|
| 475 |
+
document: Document,
|
| 476 |
+
) -> StepResult:
|
| 477 |
+
# Sprint 66 — résolution des entrées : pour chaque type
|
| 478 |
+
# demandé, on consulte ``inputs_from`` ; sinon on prend la
|
| 479 |
+
# dernière version disponible (rétrocompat Sprint 63).
|
| 480 |
+
resolved: dict[ArtifactType, Any] = {}
|
| 481 |
+
missing: list[str] = []
|
| 482 |
+
for t in step.input_types:
|
| 483 |
+
src = step.inputs_from.get(t, latest.get(t))
|
| 484 |
+
if src is None:
|
| 485 |
+
missing.append(t.value)
|
| 486 |
+
continue
|
| 487 |
+
key = (t, src)
|
| 488 |
+
if key not in versioned:
|
| 489 |
+
# Référence explicite vers une étape qui n'a pas
|
| 490 |
+
# produit cet artefact (ex. l'étape source a échoué).
|
| 491 |
+
missing.append(f"{t.value}@{src}")
|
| 492 |
+
continue
|
| 493 |
+
resolved[t] = versioned[key]
|
| 494 |
+
if missing:
|
| 495 |
+
miss_str = ",".join(missing)
|
| 496 |
+
return StepResult(
|
| 497 |
+
step_name=step.name,
|
| 498 |
+
duration_seconds=0.0,
|
| 499 |
+
output_types=(),
|
| 500 |
+
error=f"entrée manquante : {miss_str}",
|
| 501 |
+
)
|
| 502 |
+
inputs_for_module = resolved
|
| 503 |
+
# Exécution chronométrée
|
| 504 |
+
t0 = time.monotonic()
|
| 505 |
+
try:
|
| 506 |
+
outputs = step.module.process(inputs_for_module)
|
| 507 |
+
except Exception as exc: # noqa: BLE001
|
| 508 |
+
duration = time.monotonic() - t0
|
| 509 |
+
logger.warning(
|
| 510 |
+
"[pipeline_runner] étape '%s' a levé : %s",
|
| 511 |
+
step.name, exc,
|
| 512 |
+
)
|
| 513 |
+
return StepResult(
|
| 514 |
+
step_name=step.name,
|
| 515 |
+
duration_seconds=duration,
|
| 516 |
+
output_types=(),
|
| 517 |
+
error=f"{type(exc).__name__}: {exc}",
|
| 518 |
+
)
|
| 519 |
+
duration = time.monotonic() - t0
|
| 520 |
+
|
| 521 |
+
# Validation des sorties : le module est censé déclarer ses
|
| 522 |
+
# output_types, on vérifie qu'il les a tous produits. Si
|
| 523 |
+
# ce n'est pas le cas, on remonte une erreur explicite mais
|
| 524 |
+
# on conserve les sorties effectivement présentes (utile
|
| 525 |
+
# pour le diagnostic).
|
| 526 |
+
if not isinstance(outputs, dict):
|
| 527 |
+
return StepResult(
|
| 528 |
+
step_name=step.name,
|
| 529 |
+
duration_seconds=duration,
|
| 530 |
+
output_types=(),
|
| 531 |
+
error=(
|
| 532 |
+
f"le module a retourné {type(outputs).__name__}, "
|
| 533 |
+
f"un dict[ArtifactType, Any] est attendu"
|
| 534 |
+
),
|
| 535 |
+
)
|
| 536 |
+
produced = tuple(t for t in step.output_types if t in outputs)
|
| 537 |
+
missing_outputs = [t for t in step.output_types if t not in outputs]
|
| 538 |
+
error: Optional[str] = None
|
| 539 |
+
if missing_outputs:
|
| 540 |
+
miss_str = ",".join(t.value for t in missing_outputs)
|
| 541 |
+
error = f"sortie manquante : {miss_str}"
|
| 542 |
+
|
| 543 |
+
# Mise à jour du bag versionné : on stocke la sortie sous
|
| 544 |
+
# une clé (type, step.name) ET on met à jour ``latest`` pour
|
| 545 |
+
# que les étapes suivantes la récupèrent par défaut.
|
| 546 |
+
for t in produced:
|
| 547 |
+
versioned[(t, step.name)] = outputs[t]
|
| 548 |
+
latest[t] = step.name
|
| 549 |
+
|
| 550 |
+
# Évaluation aux jonctions : pour chaque type produit, si
|
| 551 |
+
# la GT du même niveau existe, on calcule les métriques.
|
| 552 |
+
junction_metrics: dict[str, dict[str, Any]] = {}
|
| 553 |
+
for at in produced:
|
| 554 |
+
gt_level = _artifact_type_to_gt_level(at)
|
| 555 |
+
if gt_level is None:
|
| 556 |
+
continue
|
| 557 |
+
gt_payload = document.get_gt(gt_level)
|
| 558 |
+
if gt_payload is None:
|
| 559 |
+
continue
|
| 560 |
+
try:
|
| 561 |
+
metrics = compute_at_junction(
|
| 562 |
+
_gt_payload_to_value(gt_payload),
|
| 563 |
+
outputs[at],
|
| 564 |
+
(at, at),
|
| 565 |
+
)
|
| 566 |
+
except Exception as exc: # noqa: BLE001
|
| 567 |
+
logger.warning(
|
| 568 |
+
"[pipeline_runner] évaluation à la jonction %s "
|
| 569 |
+
"a levé : %s",
|
| 570 |
+
at.value, exc,
|
| 571 |
+
)
|
| 572 |
+
continue
|
| 573 |
+
if metrics:
|
| 574 |
+
junction_metrics[at.value] = metrics
|
| 575 |
+
|
| 576 |
+
# Phase 4-bis : double-clé pour rétrocompat. Les tests
|
| 577 |
+
# legacy cherchent junction_metrics["text"] mais le runner
|
| 578 |
+
# peut produire junction_metrics["raw_text"] si l'enum est
|
| 579 |
+
# migré (ArtifactType.TEXT alias de RAW_TEXT, valeur
|
| 580 |
+
# "raw_text"). expand_legacy_keys ajoute la clé legacy
|
| 581 |
+
# ("text") à côté de la canonique ("raw_text") sans écraser.
|
| 582 |
+
from picarones.domain.artifacts import expand_legacy_keys
|
| 583 |
+
expand_legacy_keys(junction_metrics)
|
| 584 |
+
|
| 585 |
+
return StepResult(
|
| 586 |
+
step_name=step.name,
|
| 587 |
+
duration_seconds=duration,
|
| 588 |
+
output_types=produced,
|
| 589 |
+
junction_metrics=junction_metrics,
|
| 590 |
+
error=error,
|
| 591 |
+
)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def _gt_payload_to_value(payload: Any) -> Any:
|
| 595 |
+
"""Extrait la valeur exploitable d'un ``GTPayload`` typé.
|
| 596 |
+
|
| 597 |
+
Pour ``TextGT`` on veut juste la chaîne ; pour les autres
|
| 598 |
+
payloads on retourne le payload entier (la métrique sait quoi
|
| 599 |
+
en faire selon sa signature de types).
|
| 600 |
+
"""
|
| 601 |
+
# Import paresseux pour éviter une dépendance cyclique
|
| 602 |
+
from picarones.evaluation.corpus import (
|
| 603 |
+
AltoGT, EntitiesGT, PageGT, ReadingOrderGT, TextGT,
|
| 604 |
+
)
|
| 605 |
+
if isinstance(payload, TextGT):
|
| 606 |
+
return payload.text
|
| 607 |
+
if isinstance(payload, EntitiesGT):
|
| 608 |
+
return payload.entities
|
| 609 |
+
if isinstance(payload, ReadingOrderGT):
|
| 610 |
+
return payload.region_order
|
| 611 |
+
if isinstance(payload, (AltoGT, PageGT)):
|
| 612 |
+
return payload
|
| 613 |
+
return payload
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
__all__ = [
|
| 617 |
+
"PipelineRunner",
|
| 618 |
+
"PipelineResult",
|
| 619 |
+
"PipelineSpec",
|
| 620 |
+
"PipelineStep",
|
| 621 |
+
"StepResult",
|
| 622 |
+
]
|
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Orchestration corpus-wide d'une pipeline composée — Sprint 64
|
| 2 |
+
(axe B).
|
| 3 |
+
|
| 4 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 5 |
+
``picarones.measurements.pipeline_benchmark`` vers
|
| 6 |
+
``picarones.evaluation.pipeline_benchmark``. Le chemin legacy
|
| 7 |
+
reste disponible via un shim avec ``DeprecationWarning`` ;
|
| 8 |
+
suppression prévue en 2.0.
|
| 9 |
+
|
| 10 |
+
Sprint 64 — Étape 4 / axe B du plan d'évolution 2026 : suite directe
|
| 11 |
+
du Sprint 63. Le ``PipelineRunner`` exécute une pipeline sur **un**
|
| 12 |
+
document ; ce module fournit l'orchestration sur un **corpus
|
| 13 |
+
complet** et l'agrégation des résultats par étape.
|
| 14 |
+
|
| 15 |
+
Philosophie inchangée
|
| 16 |
+
---------------------
|
| 17 |
+
Picarones reste un **banc d'essai**. Aucun module métier n'est
|
| 18 |
+
fourni — l'utilisateur amène ses propres ``BaseModule`` (Sprint 33).
|
| 19 |
+
Cette infrastructure se contente d'orchestrer leur exécution sur un
|
| 20 |
+
corpus, de mesurer le temps, de capturer les erreurs gracieusement,
|
| 21 |
+
et d'agréger les métriques calculées aux jonctions GT-vs-sortie.
|
| 22 |
+
|
| 23 |
+
Périmètre Sprint 64
|
| 24 |
+
-------------------
|
| 25 |
+
Inclus :
|
| 26 |
+
|
| 27 |
+
- ``run_pipeline_benchmark(spec, corpus, initial_inputs_factory)``
|
| 28 |
+
qui itère séquentiellement sur les documents.
|
| 29 |
+
- Agrégation par étape : ``StepAggregate`` avec n_succeeded /
|
| 30 |
+
n_failed, durées (total / mean / median), failing_doc_ids,
|
| 31 |
+
métriques agrégées par type d'artefact (mean / median sur les
|
| 32 |
+
métriques numériques uniquement), breakdown des types d'erreur.
|
| 33 |
+
- ``PipelineBenchmarkResult`` : conteneur global avec liste des
|
| 34 |
+
``PipelineResult`` par doc + liste des ``StepAggregate``.
|
| 35 |
+
- Helper ``default_initial_inputs`` qui couvre le cas standard
|
| 36 |
+
``IMAGE`` depuis ``Document.image_path``.
|
| 37 |
+
|
| 38 |
+
Reporté à des sprints suivants :
|
| 39 |
+
|
| 40 |
+
- Comparaison de N pipelines sur le même corpus (Sprint 65).
|
| 41 |
+
- DAG branchant non séquentiel (Sprint 66).
|
| 42 |
+
- Vue HTML dédiée aux pipelines composées (Sprint 67).
|
| 43 |
+
- Parallélisation inter-documents (à arbitrer selon les besoins).
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
from __future__ import annotations
|
| 47 |
+
|
| 48 |
+
import logging
|
| 49 |
+
import statistics
|
| 50 |
+
import time
|
| 51 |
+
from dataclasses import dataclass, field
|
| 52 |
+
from typing import Any, Callable, Optional
|
| 53 |
+
|
| 54 |
+
from picarones.evaluation.corpus import Corpus, Document
|
| 55 |
+
from picarones.domain.artifacts import ArtifactType
|
| 56 |
+
from picarones.evaluation.pipeline import (
|
| 57 |
+
PipelineResult,
|
| 58 |
+
PipelineRunner,
|
| 59 |
+
PipelineSpec,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
logger = logging.getLogger(__name__)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 66 |
+
# Helpers : factory d'entrées initiales
|
| 67 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 68 |
+
|
| 69 |
+
InitialInputsFactory = Callable[[Document], dict[ArtifactType, Any]]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def default_initial_inputs(document: Document) -> dict[ArtifactType, Any]:
|
| 73 |
+
"""Factory d'entrées initiales par défaut : couvre le cas
|
| 74 |
+
« la pipeline démarre par un module qui consomme l'image ».
|
| 75 |
+
|
| 76 |
+
Retourne ``{ArtifactType.IMAGE: document.image_path}`` si
|
| 77 |
+
``image_path`` est présent, sinon dict vide (la première étape
|
| 78 |
+
devra alors signaler « entrée manquante »).
|
| 79 |
+
"""
|
| 80 |
+
if document.image_path:
|
| 81 |
+
return {ArtifactType.IMAGE: document.image_path}
|
| 82 |
+
return {}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 86 |
+
# Agrégats
|
| 87 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@dataclass
|
| 91 |
+
class StepAggregate:
|
| 92 |
+
"""Agrégat des résultats d'une étape sur tout le corpus.
|
| 93 |
+
|
| 94 |
+
Champs
|
| 95 |
+
------
|
| 96 |
+
step_name:
|
| 97 |
+
Nom de l'étape (cf. ``PipelineStep.name``).
|
| 98 |
+
n_docs:
|
| 99 |
+
Nombre de documents pour lesquels l'étape a été tentée.
|
| 100 |
+
n_succeeded:
|
| 101 |
+
Nombre de documents pour lesquels l'étape s'est terminée
|
| 102 |
+
sans erreur (``StepResult.error is None``).
|
| 103 |
+
n_failed:
|
| 104 |
+
Nombre de documents pour lesquels l'étape a renvoyé une
|
| 105 |
+
erreur.
|
| 106 |
+
duration_seconds_total / mean / median:
|
| 107 |
+
Statistiques de durée sur les **étapes ayant réussi**
|
| 108 |
+
uniquement (les étapes en erreur peuvent avoir une durée
|
| 109 |
+
artificielle).
|
| 110 |
+
failing_doc_ids:
|
| 111 |
+
Liste des ``doc_id`` pour lesquels cette étape a échoué.
|
| 112 |
+
junction_metrics:
|
| 113 |
+
``{artifact_type_value: {metric_name: {"mean": float,
|
| 114 |
+
"median": float, "n": int}}}`` — agrégé sur les documents
|
| 115 |
+
où la métrique a été calculée (n peut différer de
|
| 116 |
+
``n_succeeded`` si la GT du type n'est pas portée par tous
|
| 117 |
+
les docs).
|
| 118 |
+
error_breakdown:
|
| 119 |
+
``{type_d_erreur: count}`` où ``type_d_erreur`` est extrait
|
| 120 |
+
en heuristique depuis le message (``"missing_input"``,
|
| 121 |
+
``"raised_exception"``, ``"missing_output"``,
|
| 122 |
+
``"other"``).
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
step_name: str
|
| 126 |
+
n_docs: int = 0
|
| 127 |
+
n_succeeded: int = 0
|
| 128 |
+
n_failed: int = 0
|
| 129 |
+
duration_seconds_total: float = 0.0
|
| 130 |
+
duration_seconds_mean: float = 0.0
|
| 131 |
+
duration_seconds_median: float = 0.0
|
| 132 |
+
failing_doc_ids: list[str] = field(default_factory=list)
|
| 133 |
+
junction_metrics: dict[str, dict[str, dict[str, float]]] = field(
|
| 134 |
+
default_factory=dict,
|
| 135 |
+
)
|
| 136 |
+
error_breakdown: dict[str, int] = field(default_factory=dict)
|
| 137 |
+
|
| 138 |
+
@property
|
| 139 |
+
def success_rate(self) -> float:
|
| 140 |
+
if self.n_docs == 0:
|
| 141 |
+
return 0.0
|
| 142 |
+
return self.n_succeeded / self.n_docs
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@dataclass
|
| 146 |
+
class PipelineBenchmarkResult:
|
| 147 |
+
"""Résultat d'un benchmark de pipeline sur un corpus complet.
|
| 148 |
+
|
| 149 |
+
On capture la durée totale, les résultats par document
|
| 150 |
+
(utiles pour le rapport HTML par-doc des sprints suivants), et
|
| 151 |
+
l'agrégation par étape.
|
| 152 |
+
"""
|
| 153 |
+
|
| 154 |
+
pipeline_name: str
|
| 155 |
+
corpus_name: str
|
| 156 |
+
n_docs: int = 0
|
| 157 |
+
per_doc_results: list[PipelineResult] = field(default_factory=list)
|
| 158 |
+
per_step_aggregates: list[StepAggregate] = field(default_factory=list)
|
| 159 |
+
total_duration_seconds: float = 0.0
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def n_pipelines_succeeded(self) -> int:
|
| 163 |
+
return sum(1 for r in self.per_doc_results if r.succeeded)
|
| 164 |
+
|
| 165 |
+
@property
|
| 166 |
+
def n_pipelines_failed(self) -> int:
|
| 167 |
+
return sum(1 for r in self.per_doc_results if not r.succeeded)
|
| 168 |
+
|
| 169 |
+
def aggregate_for_step(self, step_name: str) -> Optional[StepAggregate]:
|
| 170 |
+
for agg in self.per_step_aggregates:
|
| 171 |
+
if agg.step_name == step_name:
|
| 172 |
+
return agg
|
| 173 |
+
return None
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 177 |
+
# Classification des erreurs
|
| 178 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
_ERROR_PATTERNS: tuple[tuple[str, str], ...] = (
|
| 182 |
+
("entrée manquante", "missing_input"),
|
| 183 |
+
("sortie manquante", "missing_output"),
|
| 184 |
+
("Error", "raised_exception"), # RuntimeError, ValueError…
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _classify_error(message: str) -> str:
|
| 189 |
+
"""Heuristique simple pour catégoriser une erreur d'étape.
|
| 190 |
+
|
| 191 |
+
On regarde des marqueurs lexicaux dans le message (les messages
|
| 192 |
+
sont produits par ``pipeline_runner._run_step`` qui les contrôle
|
| 193 |
+
entièrement, donc cette heuristique est stable).
|
| 194 |
+
"""
|
| 195 |
+
if not message:
|
| 196 |
+
return "other"
|
| 197 |
+
for pattern, label in _ERROR_PATTERNS:
|
| 198 |
+
if pattern in message:
|
| 199 |
+
return label
|
| 200 |
+
return "other"
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 204 |
+
# Agrégation
|
| 205 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _aggregate_step(
|
| 209 |
+
step_name: str, per_doc: list[tuple[str, Any]],
|
| 210 |
+
) -> StepAggregate:
|
| 211 |
+
"""Construit le ``StepAggregate`` pour une étape donnée.
|
| 212 |
+
|
| 213 |
+
``per_doc`` est une liste de tuples ``(doc_id, step_result)`` où
|
| 214 |
+
``step_result`` peut être ``None`` (cas où la pipeline a été
|
| 215 |
+
arrêtée en amont avant cette étape) ou un ``StepResult``.
|
| 216 |
+
"""
|
| 217 |
+
agg = StepAggregate(step_name=step_name)
|
| 218 |
+
durations_succeeded: list[float] = []
|
| 219 |
+
metrics_by_type: dict[str, dict[str, list[float]]] = {}
|
| 220 |
+
|
| 221 |
+
for doc_id, sr in per_doc:
|
| 222 |
+
if sr is None:
|
| 223 |
+
# L'étape n'a même pas été exécutée (validation amont
|
| 224 |
+
# invalide, ou exécutée n'a pas atteint l'index — ne se
|
| 225 |
+
# produit pas en séquentiel mais peut arriver avec un
|
| 226 |
+
# DAG plus tard). On compte ce cas comme échec
|
| 227 |
+
# explicite avec un type dédié.
|
| 228 |
+
agg.n_docs += 1
|
| 229 |
+
agg.n_failed += 1
|
| 230 |
+
agg.failing_doc_ids.append(doc_id)
|
| 231 |
+
agg.error_breakdown["pipeline_aborted"] = (
|
| 232 |
+
agg.error_breakdown.get("pipeline_aborted", 0) + 1
|
| 233 |
+
)
|
| 234 |
+
continue
|
| 235 |
+
agg.n_docs += 1
|
| 236 |
+
if sr.error is None:
|
| 237 |
+
agg.n_succeeded += 1
|
| 238 |
+
durations_succeeded.append(sr.duration_seconds)
|
| 239 |
+
# Collecte des métriques pour agrégation moyenne/médiane
|
| 240 |
+
for at_value, metrics in sr.junction_metrics.items():
|
| 241 |
+
slot = metrics_by_type.setdefault(at_value, {})
|
| 242 |
+
for mname, mvalue in metrics.items():
|
| 243 |
+
if isinstance(mvalue, (int, float)) and not isinstance(
|
| 244 |
+
mvalue, bool,
|
| 245 |
+
):
|
| 246 |
+
slot.setdefault(mname, []).append(float(mvalue))
|
| 247 |
+
else:
|
| 248 |
+
agg.n_failed += 1
|
| 249 |
+
agg.failing_doc_ids.append(doc_id)
|
| 250 |
+
label = _classify_error(sr.error)
|
| 251 |
+
agg.error_breakdown[label] = (
|
| 252 |
+
agg.error_breakdown.get(label, 0) + 1
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
if durations_succeeded:
|
| 256 |
+
agg.duration_seconds_total = sum(durations_succeeded)
|
| 257 |
+
agg.duration_seconds_mean = statistics.fmean(durations_succeeded)
|
| 258 |
+
agg.duration_seconds_median = statistics.median(durations_succeeded)
|
| 259 |
+
|
| 260 |
+
for at_value, metrics in metrics_by_type.items():
|
| 261 |
+
agg.junction_metrics[at_value] = {
|
| 262 |
+
mname: {
|
| 263 |
+
"mean": statistics.fmean(values),
|
| 264 |
+
"median": statistics.median(values),
|
| 265 |
+
"n": len(values),
|
| 266 |
+
}
|
| 267 |
+
for mname, values in metrics.items()
|
| 268 |
+
}
|
| 269 |
+
# Phase 4-bis : double-clé legacy/canonique pour rétrocompat.
|
| 270 |
+
from picarones.domain.artifacts import expand_legacy_keys
|
| 271 |
+
expand_legacy_keys(agg.junction_metrics)
|
| 272 |
+
return agg
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 276 |
+
# Orchestrateur principal
|
| 277 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def run_pipeline_benchmark(
|
| 281 |
+
spec: PipelineSpec,
|
| 282 |
+
corpus: Corpus,
|
| 283 |
+
initial_inputs_factory: InitialInputsFactory = default_initial_inputs,
|
| 284 |
+
) -> PipelineBenchmarkResult:
|
| 285 |
+
"""Exécute ``spec`` sur tous les documents de ``corpus``.
|
| 286 |
+
|
| 287 |
+
Parameters
|
| 288 |
+
----------
|
| 289 |
+
spec:
|
| 290 |
+
Spécification de la pipeline composée. Toutes les étapes
|
| 291 |
+
sont des ``BaseModule`` fournis par l'utilisateur.
|
| 292 |
+
corpus:
|
| 293 |
+
Corpus chargé via ``Corpus.from_directory`` ou équivalent.
|
| 294 |
+
initial_inputs_factory:
|
| 295 |
+
Fonction qui produit, pour chaque document, les artefacts
|
| 296 |
+
d'entrée de la pipeline. Par défaut : ``IMAGE`` depuis
|
| 297 |
+
``document.image_path``. L'utilisateur peut fournir une
|
| 298 |
+
factory personnalisée pour brancher d'autres sources
|
| 299 |
+
(par exemple ``ALTO`` pré-existant pour évaluer un
|
| 300 |
+
pipeline qui démarre par un re-segmenteur).
|
| 301 |
+
|
| 302 |
+
Returns
|
| 303 |
+
-------
|
| 304 |
+
PipelineBenchmarkResult
|
| 305 |
+
Résultat global avec ``per_doc_results``,
|
| 306 |
+
``per_step_aggregates``, durée totale.
|
| 307 |
+
|
| 308 |
+
Comportement
|
| 309 |
+
------------
|
| 310 |
+
L'orchestration est **séquentielle** par document. Pour chaque
|
| 311 |
+
document, ``PipelineRunner.run`` est appelé ; quel que soit le
|
| 312 |
+
résultat (réussi, partiellement échoué, totalement invalide),
|
| 313 |
+
le résultat est ajouté à ``per_doc_results`` et le benchmark
|
| 314 |
+
continue avec le document suivant.
|
| 315 |
+
|
| 316 |
+
Si la spec est statiquement invalide (cf.
|
| 317 |
+
``PipelineSpec.validate``), tous les documents auront un
|
| 318 |
+
``PipelineResult.error`` non vide et aucune étape ne sera
|
| 319 |
+
exécutée — le résultat reste cohérent.
|
| 320 |
+
"""
|
| 321 |
+
result = PipelineBenchmarkResult(
|
| 322 |
+
pipeline_name=spec.name, corpus_name=corpus.name,
|
| 323 |
+
)
|
| 324 |
+
documents = list(corpus.documents)
|
| 325 |
+
result.n_docs = len(documents)
|
| 326 |
+
|
| 327 |
+
benchmark_t0 = time.monotonic()
|
| 328 |
+
for doc in documents:
|
| 329 |
+
try:
|
| 330 |
+
initial = initial_inputs_factory(doc)
|
| 331 |
+
except Exception as exc: # noqa: BLE001
|
| 332 |
+
logger.warning(
|
| 333 |
+
"[pipeline_benchmark] factory a levé sur %s : %s",
|
| 334 |
+
doc.doc_id, exc,
|
| 335 |
+
)
|
| 336 |
+
# On crée un PipelineResult portant l'erreur factory
|
| 337 |
+
failed = PipelineResult(
|
| 338 |
+
pipeline_name=spec.name, doc_id=doc.doc_id,
|
| 339 |
+
error=f"initial_inputs_factory: {type(exc).__name__}: {exc}",
|
| 340 |
+
)
|
| 341 |
+
result.per_doc_results.append(failed)
|
| 342 |
+
continue
|
| 343 |
+
per_doc = PipelineRunner.run(spec, doc, initial)
|
| 344 |
+
result.per_doc_results.append(per_doc)
|
| 345 |
+
result.total_duration_seconds = time.monotonic() - benchmark_t0
|
| 346 |
+
|
| 347 |
+
# Agrégation par étape
|
| 348 |
+
step_names = [step.name for step in spec.steps]
|
| 349 |
+
for idx, step_name in enumerate(step_names):
|
| 350 |
+
per_doc_step: list[tuple[str, Any]] = []
|
| 351 |
+
for pr in result.per_doc_results:
|
| 352 |
+
if idx < len(pr.steps):
|
| 353 |
+
per_doc_step.append((pr.doc_id, pr.steps[idx]))
|
| 354 |
+
else:
|
| 355 |
+
# Pipeline a été arrêtée en amont : aucune étape de
|
| 356 |
+
# cet index n'existe. On compte ça comme une
|
| 357 |
+
# absence d'étape (cf. ``_aggregate_step`` qui gère
|
| 358 |
+
# le ``None``).
|
| 359 |
+
per_doc_step.append((pr.doc_id, None))
|
| 360 |
+
result.per_step_aggregates.append(
|
| 361 |
+
_aggregate_step(step_name, per_doc_step),
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
return result
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
__all__ = [
|
| 368 |
+
"InitialInputsFactory",
|
| 369 |
+
"PipelineBenchmarkResult",
|
| 370 |
+
"StepAggregate",
|
| 371 |
+
"default_initial_inputs",
|
| 372 |
+
"run_pipeline_benchmark",
|
| 373 |
+
]
|
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Comparaison de N pipelines sur le même corpus — Sprint 65 (axe B).
|
| 2 |
+
|
| 3 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 4 |
+
``picarones.measurements.pipeline_comparison`` vers
|
| 5 |
+
``picarones.evaluation.pipeline_comparison``. Le chemin legacy
|
| 6 |
+
reste disponible via un shim avec ``DeprecationWarning`` ;
|
| 7 |
+
suppression prévue en 2.0.
|
| 8 |
+
|
| 9 |
+
Sprint 65 — Étape 4 / axe B du plan d'évolution 2026 : suite directe
|
| 10 |
+
des Sprints 63-64. Le runner mono-document (Sprint 63) et
|
| 11 |
+
l'orchestration corpus-wide (Sprint 64) permettent d'évaluer **une**
|
| 12 |
+
pipeline composée ; ce sprint répond à la question typique BnF :
|
| 13 |
+
|
| 14 |
+
« OCR seul vs OCR+correcteur A vs OCR+correcteur B :
|
| 15 |
+
laquelle est la meilleure sur mon corpus, et de combien ? »
|
| 16 |
+
|
| 17 |
+
Philosophie inchangée
|
| 18 |
+
---------------------
|
| 19 |
+
Picarones reste un **banc d'essai** — on juge des pipelines tierces
|
| 20 |
+
sur le **même corpus** avec la **même GT**, en exposant des chiffres
|
| 21 |
+
bruts comparatifs. Aucun verdict imposé : le chercheur lit le
|
| 22 |
+
ranking et la table de gain et conclut selon ses critères.
|
| 23 |
+
|
| 24 |
+
Périmètre Sprint 65
|
| 25 |
+
-------------------
|
| 26 |
+
Inclus :
|
| 27 |
+
|
| 28 |
+
- ``compare_pipelines(specs, corpus, factories=None)`` qui exécute
|
| 29 |
+
séquentiellement N pipelines sur le même corpus.
|
| 30 |
+
- ``PipelineComparisonResult`` : conteneur avec
|
| 31 |
+
``per_pipeline: dict[name → PipelineBenchmarkResult]``,
|
| 32 |
+
``ranking_by_final_metric(artifact_type, metric_name,
|
| 33 |
+
higher_is_better)`` qui retourne ``[(pipeline_name, score), ...]``
|
| 34 |
+
trié, et ``gain_table(artifact_type, metric_name,
|
| 35 |
+
baseline_pipeline)`` qui retourne pour chaque pipeline le
|
| 36 |
+
``{absolute, relative}`` vs baseline.
|
| 37 |
+
- ``factories``: dict ``{pipeline_name: InitialInputsFactory}`` pour
|
| 38 |
+
personnaliser les entrées initiales par pipeline (utile pour
|
| 39 |
+
comparer une pipeline qui démarre par IMAGE et une qui démarre
|
| 40 |
+
par TEXT).
|
| 41 |
+
- Garde-fou : noms de pipelines uniques exigés.
|
| 42 |
+
|
| 43 |
+
Reporté à des sprints suivants :
|
| 44 |
+
|
| 45 |
+
- DAG branchant non séquentiel (Sprint 66).
|
| 46 |
+
- Vue HTML dédiée à la comparaison de pipelines (Sprint 67+).
|
| 47 |
+
- Tests statistiques (Wilcoxon, Friedman, Nemenyi) sur les
|
| 48 |
+
pipelines composées — déjà disponibles côté OCR (Sprint 18) ;
|
| 49 |
+
l'application au cadre pipeline arrive plus tard.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import logging
|
| 55 |
+
import time
|
| 56 |
+
from dataclasses import dataclass, field
|
| 57 |
+
from typing import Optional
|
| 58 |
+
|
| 59 |
+
from picarones.evaluation.corpus import Corpus
|
| 60 |
+
from picarones.domain.artifacts import ArtifactType
|
| 61 |
+
from picarones.evaluation.pipeline_benchmark import (
|
| 62 |
+
InitialInputsFactory,
|
| 63 |
+
PipelineBenchmarkResult,
|
| 64 |
+
default_initial_inputs,
|
| 65 |
+
run_pipeline_benchmark,
|
| 66 |
+
)
|
| 67 |
+
from picarones.evaluation.pipeline import PipelineSpec
|
| 68 |
+
|
| 69 |
+
logger = logging.getLogger(__name__)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 73 |
+
# Conteneur de résultats
|
| 74 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class PipelineComparisonResult:
|
| 79 |
+
"""Résultat de la comparaison de N pipelines sur un corpus.
|
| 80 |
+
|
| 81 |
+
Champs
|
| 82 |
+
------
|
| 83 |
+
corpus_name:
|
| 84 |
+
Nom du corpus (commun à toutes les pipelines comparées).
|
| 85 |
+
n_docs:
|
| 86 |
+
Nombre de documents du corpus.
|
| 87 |
+
per_pipeline:
|
| 88 |
+
Map ``{pipeline_name: PipelineBenchmarkResult}``. L'ordre
|
| 89 |
+
d'insertion suit l'ordre des ``specs`` passées à
|
| 90 |
+
``compare_pipelines`` ; on s'appuie sur le ``dict`` ordonné
|
| 91 |
+
de Python 3.7+.
|
| 92 |
+
total_duration_seconds:
|
| 93 |
+
Durée totale de la comparaison (sommes des durées par
|
| 94 |
+
pipeline + petit overhead).
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
corpus_name: str
|
| 98 |
+
n_docs: int = 0
|
| 99 |
+
per_pipeline: dict[str, PipelineBenchmarkResult] = field(
|
| 100 |
+
default_factory=dict,
|
| 101 |
+
)
|
| 102 |
+
total_duration_seconds: float = 0.0
|
| 103 |
+
|
| 104 |
+
def pipeline_names(self) -> list[str]:
|
| 105 |
+
"""Retourne la liste des noms de pipelines dans leur ordre
|
| 106 |
+
d'insertion (= ordre de la comparaison initiale)."""
|
| 107 |
+
return list(self.per_pipeline.keys())
|
| 108 |
+
|
| 109 |
+
def _final_metric_value(
|
| 110 |
+
self,
|
| 111 |
+
pipeline_name: str,
|
| 112 |
+
artifact_type: ArtifactType,
|
| 113 |
+
metric_name: str,
|
| 114 |
+
) -> Optional[float]:
|
| 115 |
+
"""Retourne le ``mean`` de la métrique demandée à la
|
| 116 |
+
**dernière étape** de la pipeline qui a produit
|
| 117 |
+
``artifact_type`` (avec succès sur ≥ 1 doc), ou ``None``
|
| 118 |
+
si la métrique n'est pas disponible.
|
| 119 |
+
|
| 120 |
+
Cohérent avec ``PipelineResult.junction_metrics_for`` du
|
| 121 |
+
Sprint 63 mais au niveau corpus-wide.
|
| 122 |
+
"""
|
| 123 |
+
bench = self.per_pipeline.get(pipeline_name)
|
| 124 |
+
if bench is None:
|
| 125 |
+
return None
|
| 126 |
+
from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
|
| 127 |
+
legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
|
| 128 |
+
for agg in reversed(bench.per_step_aggregates):
|
| 129 |
+
type_metrics = agg.junction_metrics.get(artifact_type.value)
|
| 130 |
+
if not type_metrics and legacy_alias is not None:
|
| 131 |
+
# Phase 4-bis : un caller (typiquement les tests
|
| 132 |
+
# ou un agrégateur tiers) peut avoir construit le
|
| 133 |
+
# dict avec la clé legacy ``"text"`` au lieu de la
|
| 134 |
+
# canonique ``"raw_text"``. expand_legacy_keys
|
| 135 |
+
# synchronise les deux côtés sur les sites
|
| 136 |
+
# d'écriture du runner — ce fallback couvre le
|
| 137 |
+
# reste.
|
| 138 |
+
type_metrics = agg.junction_metrics.get(legacy_alias)
|
| 139 |
+
if not type_metrics:
|
| 140 |
+
continue
|
| 141 |
+
stats = type_metrics.get(metric_name)
|
| 142 |
+
if stats is None:
|
| 143 |
+
continue
|
| 144 |
+
return stats["mean"]
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
def ranking_by_final_metric(
|
| 148 |
+
self,
|
| 149 |
+
artifact_type: ArtifactType,
|
| 150 |
+
metric_name: str,
|
| 151 |
+
higher_is_better: bool = False,
|
| 152 |
+
) -> list[tuple[str, Optional[float]]]:
|
| 153 |
+
"""Classe les pipelines par la valeur **finale** de
|
| 154 |
+
``metric_name`` à la jonction ``artifact_type``.
|
| 155 |
+
|
| 156 |
+
Returns
|
| 157 |
+
-------
|
| 158 |
+
list[tuple[str, Optional[float]]]
|
| 159 |
+
Liste ``[(pipeline_name, mean_value)]`` triée :
|
| 160 |
+
|
| 161 |
+
- Les pipelines avec une valeur définie viennent en
|
| 162 |
+
premier, triées selon ``higher_is_better``.
|
| 163 |
+
- Les pipelines sans valeur (métrique absente) viennent
|
| 164 |
+
en queue, dans leur ordre d'insertion.
|
| 165 |
+
"""
|
| 166 |
+
with_value: list[tuple[str, float]] = []
|
| 167 |
+
without_value: list[tuple[str, Optional[float]]] = []
|
| 168 |
+
for name in self.pipeline_names():
|
| 169 |
+
value = self._final_metric_value(name, artifact_type, metric_name)
|
| 170 |
+
if value is None:
|
| 171 |
+
without_value.append((name, None))
|
| 172 |
+
else:
|
| 173 |
+
with_value.append((name, value))
|
| 174 |
+
with_value.sort(
|
| 175 |
+
key=lambda pair: pair[1],
|
| 176 |
+
reverse=higher_is_better,
|
| 177 |
+
)
|
| 178 |
+
return [*with_value, *without_value]
|
| 179 |
+
|
| 180 |
+
def gain_table(
|
| 181 |
+
self,
|
| 182 |
+
artifact_type: ArtifactType,
|
| 183 |
+
metric_name: str,
|
| 184 |
+
baseline_pipeline: str,
|
| 185 |
+
) -> dict[str, dict[str, Optional[float]]]:
|
| 186 |
+
"""Calcule l'écart de chaque pipeline vs la baseline.
|
| 187 |
+
|
| 188 |
+
Returns
|
| 189 |
+
-------
|
| 190 |
+
dict
|
| 191 |
+
Map ``{pipeline_name: {"value", "absolute", "relative"}}``
|
| 192 |
+
où :
|
| 193 |
+
|
| 194 |
+
- ``value`` : valeur finale de la métrique pour cette
|
| 195 |
+
pipeline (``None`` si absente).
|
| 196 |
+
- ``absolute`` : ``value - baseline_value``
|
| 197 |
+
(``None`` si l'une des deux est absente).
|
| 198 |
+
- ``relative`` : ``(value - baseline_value) /
|
| 199 |
+
baseline_value`` (``None`` si baseline absente ou
|
| 200 |
+
égale à 0).
|
| 201 |
+
|
| 202 |
+
La baseline elle-même apparaît avec ``absolute == 0`` et
|
| 203 |
+
``relative == 0``.
|
| 204 |
+
"""
|
| 205 |
+
if baseline_pipeline not in self.per_pipeline:
|
| 206 |
+
raise KeyError(
|
| 207 |
+
f"baseline {baseline_pipeline!r} absente de la comparaison",
|
| 208 |
+
)
|
| 209 |
+
baseline_value = self._final_metric_value(
|
| 210 |
+
baseline_pipeline, artifact_type, metric_name,
|
| 211 |
+
)
|
| 212 |
+
out: dict[str, dict[str, Optional[float]]] = {}
|
| 213 |
+
for name in self.pipeline_names():
|
| 214 |
+
value = self._final_metric_value(
|
| 215 |
+
name, artifact_type, metric_name,
|
| 216 |
+
)
|
| 217 |
+
absolute: Optional[float]
|
| 218 |
+
relative: Optional[float]
|
| 219 |
+
if value is None or baseline_value is None:
|
| 220 |
+
absolute = None
|
| 221 |
+
relative = None
|
| 222 |
+
else:
|
| 223 |
+
absolute = value - baseline_value
|
| 224 |
+
relative = (
|
| 225 |
+
(value - baseline_value) / baseline_value
|
| 226 |
+
if baseline_value != 0 else None
|
| 227 |
+
)
|
| 228 |
+
out[name] = {
|
| 229 |
+
"value": value,
|
| 230 |
+
"absolute": absolute,
|
| 231 |
+
"relative": relative,
|
| 232 |
+
}
|
| 233 |
+
return out
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 237 |
+
# Orchestrateur
|
| 238 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def compare_pipelines(
|
| 242 |
+
specs: list[PipelineSpec],
|
| 243 |
+
corpus: Corpus,
|
| 244 |
+
factories: Optional[dict[str, InitialInputsFactory]] = None,
|
| 245 |
+
) -> PipelineComparisonResult:
|
| 246 |
+
"""Exécute N ``PipelineSpec`` sur le **même** ``corpus``.
|
| 247 |
+
|
| 248 |
+
Parameters
|
| 249 |
+
----------
|
| 250 |
+
specs:
|
| 251 |
+
Liste de ``PipelineSpec``. Les noms de pipelines doivent
|
| 252 |
+
être uniques (sinon ``ValueError``).
|
| 253 |
+
corpus:
|
| 254 |
+
Corpus partagé entre toutes les pipelines comparées —
|
| 255 |
+
c'est le point fort du sprint : même corpus, même GT, on
|
| 256 |
+
peut comparer apple-to-apple.
|
| 257 |
+
factories:
|
| 258 |
+
Optionnel. Si fourni, dict ``{pipeline_name:
|
| 259 |
+
InitialInputsFactory}`` pour personnaliser les entrées
|
| 260 |
+
initiales par pipeline. Les pipelines absentes du dict
|
| 261 |
+
utilisent ``default_initial_inputs`` (cas standard
|
| 262 |
+
``IMAGE`` depuis ``Document.image_path``).
|
| 263 |
+
|
| 264 |
+
Returns
|
| 265 |
+
-------
|
| 266 |
+
PipelineComparisonResult
|
| 267 |
+
Conteneur avec ``per_pipeline`` indexé par nom et
|
| 268 |
+
utilitaires comparatifs (``ranking_by_final_metric``,
|
| 269 |
+
``gain_table``).
|
| 270 |
+
|
| 271 |
+
Raises
|
| 272 |
+
------
|
| 273 |
+
ValueError
|
| 274 |
+
Si deux ``PipelineSpec`` ont le même nom (impossible alors
|
| 275 |
+
de les distinguer dans le résultat).
|
| 276 |
+
"""
|
| 277 |
+
names = [s.name for s in specs]
|
| 278 |
+
if len(set(names)) != len(names):
|
| 279 |
+
seen: set[str] = set()
|
| 280 |
+
duplicates: list[str] = []
|
| 281 |
+
for n in names:
|
| 282 |
+
if n in seen:
|
| 283 |
+
duplicates.append(n)
|
| 284 |
+
seen.add(n)
|
| 285 |
+
raise ValueError(
|
| 286 |
+
f"noms de pipelines non uniques : {sorted(set(duplicates))}",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
factories = factories or {}
|
| 290 |
+
result = PipelineComparisonResult(
|
| 291 |
+
corpus_name=corpus.name,
|
| 292 |
+
n_docs=len(list(corpus.documents)),
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
t0 = time.monotonic()
|
| 296 |
+
for spec in specs:
|
| 297 |
+
factory = factories.get(spec.name, default_initial_inputs)
|
| 298 |
+
bench = run_pipeline_benchmark(spec, corpus, factory)
|
| 299 |
+
result.per_pipeline[spec.name] = bench
|
| 300 |
+
result.total_duration_seconds = time.monotonic() - t0
|
| 301 |
+
return result
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
__all__ = [
|
| 305 |
+
"PipelineComparisonResult",
|
| 306 |
+
"compare_pipelines",
|
| 307 |
+
]
|
|
@@ -267,7 +267,7 @@ def _searchability_hook(*, ground_truth, hypothesis, **_):
|
|
| 267 |
profiles=_STANDARD_PROFILES,
|
| 268 |
)
|
| 269 |
def _numerical_sequences_hook(*, ground_truth, hypothesis, **_):
|
| 270 |
-
from picarones.
|
| 271 |
compute_numerical_sequence_metrics_adaptive,
|
| 272 |
)
|
| 273 |
return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis)
|
|
@@ -567,7 +567,7 @@ def _aggregate_searchability(doc_results: list) -> Optional[dict]:
|
|
| 567 |
profiles=_STANDARD_PROFILES,
|
| 568 |
)
|
| 569 |
def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]:
|
| 570 |
-
from picarones.
|
| 571 |
aggregate_numerical_sequence_metrics,
|
| 572 |
)
|
| 573 |
return aggregate_numerical_sequence_metrics(
|
|
|
|
| 267 |
profiles=_STANDARD_PROFILES,
|
| 268 |
)
|
| 269 |
def _numerical_sequences_hook(*, ground_truth, hypothesis, **_):
|
| 270 |
+
from picarones.evaluation.metrics.numerical_sequences_hooks import (
|
| 271 |
compute_numerical_sequence_metrics_adaptive,
|
| 272 |
)
|
| 273 |
return compute_numerical_sequence_metrics_adaptive(ground_truth, hypothesis)
|
|
|
|
| 567 |
profiles=_STANDARD_PROFILES,
|
| 568 |
)
|
| 569 |
def _aggregate_numerical_sequences(doc_results: list) -> Optional[dict]:
|
| 570 |
+
from picarones.evaluation.metrics.numerical_sequences_hooks import (
|
| 571 |
aggregate_numerical_sequence_metrics,
|
| 572 |
)
|
| 573 |
return aggregate_numerical_sequence_metrics(
|
|
@@ -1,422 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
Pourquoi ce module
|
| 6 |
-
------------------
|
| 7 |
-
Pour un économiste-historien, un éditeur de chartes ou un
|
| 8 |
-
archiviste, la **fidélité aux séquences numériques** est un
|
| 9 |
-
proxy direct de la qualité éditoriale. Un OCR qui rate
|
| 10 |
-
*« 1789 »* dans une charte révolutionnaire ou *« f. 12v »*
|
| 11 |
-
dans une cote d'archives produit un corpus inutilisable pour la
|
| 12 |
-
recherche fine, même si le CER global est respectable.
|
| 13 |
-
|
| 14 |
-
Catégories couvertes
|
| 15 |
-
--------------------
|
| 16 |
-
1. **Dates arabes** : ``1789``, ``1450``, ``1ᵉʳ janvier 1789``
|
| 17 |
-
(le module détecte les **années** sur 4 chiffres dans la
|
| 18 |
-
plage [1000-2099]).
|
| 19 |
-
2. **Numéraux romains** : ``MDCLXVIII``, ``XIV``, ``Tome IV``.
|
| 20 |
-
Réutilise ``picarones.measurements.roman_numerals`` (Sprint 60).
|
| 21 |
-
3. **Foliotation** : ``f. 12``, ``f. 12r``, ``fol. 24v``,
|
| 22 |
-
``p. 5``, ``pp. 12-15``, ``n° 42``.
|
| 23 |
-
4. **Montants** : ``12 livres``, ``5 sols``, ``8 deniers``,
|
| 24 |
-
``100 £``, ``50 ₣``, ``20 €``, formes Ancien Régime
|
| 25 |
-
(``l.``, ``s.``, ``d.``).
|
| 26 |
-
5. **Années régnales** : ``an III``, ``l'an V``, ``an de
|
| 27 |
-
grâce 1450``, ``an de la République``.
|
| 28 |
-
|
| 29 |
-
Méthode
|
| 30 |
-
-------
|
| 31 |
-
Pour chaque catégorie, on extrait les occurrences (regex
|
| 32 |
-
spécialisée) en GT et en hypothèse. On classe ensuite chaque
|
| 33 |
-
GT en **3 statuts** :
|
| 34 |
-
|
| 35 |
-
- ``strict_preserved`` : forme exacte présente dans
|
| 36 |
-
l'hypothèse (sensible à la casse seulement pour la
|
| 37 |
-
foliotation, sinon la convention est documentée par
|
| 38 |
-
catégorie) ;
|
| 39 |
-
- ``value_preserved`` : la **valeur** apparaît même si la
|
| 40 |
-
forme diffère (ex. ``XIV`` GT et ``14`` hypothèse —
|
| 41 |
-
considéré comme valeur préservée mais forme non) ;
|
| 42 |
-
- ``lost`` : aucune trace exploitable.
|
| 43 |
-
|
| 44 |
-
Sortie
|
| 45 |
-
------
|
| 46 |
-
``compute_numerical_sequence_metrics(reference, hypothesis)``
|
| 47 |
-
retourne :
|
| 48 |
-
|
| 49 |
-
```
|
| 50 |
-
{
|
| 51 |
-
"global_strict_score": float, # ∈ [0, 1]
|
| 52 |
-
"global_value_score": float, # ∈ [0, 1]
|
| 53 |
-
"n_total": int,
|
| 54 |
-
"per_category": {
|
| 55 |
-
"year": {"n_total": int, "strict": int, "value": int,
|
| 56 |
-
"strict_score": float, "value_score": float,
|
| 57 |
-
"lost_items": list[str]},
|
| 58 |
-
"roman": {...},
|
| 59 |
-
"foliation": {...},
|
| 60 |
-
"currency": {...},
|
| 61 |
-
"regnal": {...},
|
| 62 |
-
},
|
| 63 |
-
}
|
| 64 |
-
```
|
| 65 |
-
|
| 66 |
-
Limites
|
| 67 |
-
-------
|
| 68 |
-
- Les regex sont **conservatrices** : on rate quelques
|
| 69 |
-
formes rares plutôt que de produire des faux positifs (par
|
| 70 |
-
exemple, ``mil cinq cens`` en français médiéval n'est pas
|
| 71 |
-
détecté comme année — la couche calcul s'en tient aux
|
| 72 |
-
formes les plus reconnaissables). Pour un corpus
|
| 73 |
-
spécifique, l'utilisateur peut composer ses propres
|
| 74 |
-
détecteurs et les passer via ``custom_detectors``.
|
| 75 |
-
- ``value_preserved`` exige une équivalence de **valeur
|
| 76 |
-
numérique** : ``XIV`` ↔ ``14`` est OK pour les romains ;
|
| 77 |
-
``f. 12v`` ↔ ``f. 12r`` n'est **pas** OK pour la
|
| 78 |
-
foliotation (recto/verso est une information distincte).
|
| 79 |
"""
|
| 80 |
|
| 81 |
from __future__ import annotations
|
| 82 |
|
| 83 |
-
import
|
| 84 |
-
import re
|
| 85 |
-
from typing import Optional
|
| 86 |
-
|
| 87 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 88 |
-
from picarones.domain.artifacts import ArtifactType
|
| 89 |
-
from picarones.measurements.roman_numerals import (
|
| 90 |
-
detect_roman_numerals,
|
| 91 |
-
roman_to_int,
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
-
logger = logging.getLogger(__name__)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 98 |
-
# Constantes / catégories
|
| 99 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
CATEGORIES = ("year", "roman", "foliation", "currency", "regnal")
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
# Dates arabes — 4 chiffres dans la plage [1000-2099].
|
| 106 |
-
# On exige une frontière de mot pour ne pas attraper
|
| 107 |
-
# « 12345 » (volume) ou « 0001 » (numéro de page).
|
| 108 |
-
_RE_YEAR = re.compile(r"\b(1[0-9]{3}|20[0-9]{2})\b")
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
# Foliotation : f. 12, f. 12r, fol. 24v, p. 5, pp. 12-15, n° 42
|
| 112 |
-
# La capture conserve la forme intégrale (avec ponctuation et
|
| 113 |
-
# r/v) parce que recto/verso est une information distincte.
|
| 114 |
-
_RE_FOLIATION = re.compile(
|
| 115 |
-
r"\b(?:fol\.?|f\.|pp\.|p\.|n\.°|n°)\s*" # préfixe : fol., f., pp., p., n°
|
| 116 |
-
r"(\d+(?:\s*-\s*\d+)?)" # nombre ou plage (12 / 12-15)
|
| 117 |
-
r"\s*([rvRV])?", # suffixe optionnel r/v
|
| 118 |
-
re.UNICODE,
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
# Montants : nombre suivi d'une unité monétaire.
|
| 123 |
-
# On accepte espaces multiples mais pas de saut de ligne.
|
| 124 |
-
_RE_CURRENCY = re.compile(
|
| 125 |
-
r"\b(\d+(?:[.,]\d+)?)\s*" # montant (entier ou décimal)
|
| 126 |
-
r"(livres?|sols?|deniers?|��cus?|florins?|francs?|"
|
| 127 |
-
r"l\.|s\.|d\.|£|€|₣)" # unité
|
| 128 |
-
r"(?=\b|[\s,;.!?:]|$)", # frontière souple post-symbole
|
| 129 |
-
re.UNICODE | re.IGNORECASE,
|
| 130 |
-
)
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
# Années régnales : « an III », « an de grâce 1450 »,
|
| 134 |
-
# « l'an V de la République ».
|
| 135 |
-
# Capture le numéral (romain ou arabe).
|
| 136 |
-
_RE_REGNAL = re.compile(
|
| 137 |
-
r"\b(?:l['’]\s*)?an\s+(?:de\s+(?:grâce|la\s+R[eé]publique)\s+)?"
|
| 138 |
-
r"([IVXLCDMivxlcdm]+|\d{1,4})\b",
|
| 139 |
-
re.UNICODE,
|
| 140 |
-
)
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 144 |
-
# Détection par catégorie
|
| 145 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def _detect_years(text: str) -> list[tuple[str, int]]:
|
| 149 |
-
"""Retourne [(forme, valeur)] pour chaque année 4 chiffres."""
|
| 150 |
-
if not text:
|
| 151 |
-
return []
|
| 152 |
-
return [(m.group(0), int(m.group(0))) for m in _RE_YEAR.finditer(text)]
|
| 153 |
|
|
|
|
| 154 |
|
| 155 |
-
|
| 156 |
-
"
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
if not text:
|
| 161 |
-
return []
|
| 162 |
-
out: list[tuple[str, int]] = []
|
| 163 |
-
for _start, form, value in detect_roman_numerals(text, min_length=2):
|
| 164 |
-
if value is not None:
|
| 165 |
-
out.append((form, value))
|
| 166 |
-
return out
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def _detect_foliations(text: str) -> list[tuple[str, str]]:
|
| 170 |
-
"""Foliotation. Retourne [(forme_complète, clé_normalisée)] où la
|
| 171 |
-
clé inclut le suffixe r/v normalisé (recto/verso).
|
| 172 |
-
"""
|
| 173 |
-
if not text:
|
| 174 |
-
return []
|
| 175 |
-
out: list[tuple[str, str]] = []
|
| 176 |
-
for m in _RE_FOLIATION.finditer(text):
|
| 177 |
-
full = m.group(0).strip()
|
| 178 |
-
nums = re.sub(r"\s+", "", m.group(1)) # ex : "12-15"
|
| 179 |
-
suffix = (m.group(2) or "").lower()
|
| 180 |
-
key = f"{nums}{suffix}"
|
| 181 |
-
out.append((full, key))
|
| 182 |
-
return out
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
def _detect_currencies(text: str) -> list[tuple[str, tuple[str, str]]]:
|
| 186 |
-
"""Montants. Clé = (montant_normalisé, unité_canonique).
|
| 187 |
-
|
| 188 |
-
L'unité canonique compresse les variantes (« livres » et
|
| 189 |
-
« livre » → « livre » ; « £ » reste « £ »).
|
| 190 |
-
"""
|
| 191 |
-
if not text:
|
| 192 |
-
return []
|
| 193 |
-
canon = {
|
| 194 |
-
"livre": "livre", "livres": "livre", "l.": "livre",
|
| 195 |
-
"sol": "sol", "sols": "sol", "s.": "sol",
|
| 196 |
-
"denier": "denier", "deniers": "denier", "d.": "denier",
|
| 197 |
-
"écu": "écu", "écus": "écu",
|
| 198 |
-
"florin": "florin", "florins": "florin",
|
| 199 |
-
"franc": "franc", "francs": "franc",
|
| 200 |
-
"£": "£", "€": "€", "₣": "₣",
|
| 201 |
-
}
|
| 202 |
-
out: list[tuple[str, tuple[str, str]]] = []
|
| 203 |
-
for m in _RE_CURRENCY.finditer(text):
|
| 204 |
-
amount = m.group(1).replace(",", ".")
|
| 205 |
-
unit_raw = m.group(2).lower()
|
| 206 |
-
unit = canon.get(unit_raw, unit_raw)
|
| 207 |
-
out.append((m.group(0), (amount, unit)))
|
| 208 |
-
return out
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
def _detect_regnal(text: str) -> list[tuple[str, int]]:
|
| 212 |
-
"""Années régnales. Retourne [(forme, valeur_int)] avec la
|
| 213 |
-
valeur extraite (romain → int ou arabe → int).
|
| 214 |
-
"""
|
| 215 |
-
if not text:
|
| 216 |
-
return []
|
| 217 |
-
out: list[tuple[str, int]] = []
|
| 218 |
-
for m in _RE_REGNAL.finditer(text):
|
| 219 |
-
numeral = m.group(1)
|
| 220 |
-
value: Optional[int]
|
| 221 |
-
if numeral.isdigit():
|
| 222 |
-
value = int(numeral)
|
| 223 |
-
else:
|
| 224 |
-
value = roman_to_int(numeral)
|
| 225 |
-
if value is not None:
|
| 226 |
-
out.append((m.group(0), value))
|
| 227 |
-
return out
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
_DETECTORS = {
|
| 231 |
-
"year": _detect_years,
|
| 232 |
-
"roman": _detect_romans_with_values,
|
| 233 |
-
"foliation": _detect_foliations,
|
| 234 |
-
"currency": _detect_currencies,
|
| 235 |
-
"regnal": _detect_regnal,
|
| 236 |
-
}
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 240 |
-
# Calcul principal
|
| 241 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
def _classify_per_category(
|
| 245 |
-
gt_items: list,
|
| 246 |
-
hyp_items: list,
|
| 247 |
-
*,
|
| 248 |
-
form_extractor,
|
| 249 |
-
value_extractor,
|
| 250 |
-
) -> dict:
|
| 251 |
-
"""Pour chaque item GT, le classe en strict_preserved /
|
| 252 |
-
value_preserved / lost.
|
| 253 |
-
|
| 254 |
-
Multiplicité respectée : un item hypothèse ne peut servir
|
| 255 |
-
qu'à un seul match (forme prioritaire sur valeur).
|
| 256 |
-
"""
|
| 257 |
-
hyp_used = [False] * len(hyp_items)
|
| 258 |
-
n_strict = 0
|
| 259 |
-
n_value = 0
|
| 260 |
-
lost: list[str] = []
|
| 261 |
-
# Première passe : matchs stricts (forme exacte)
|
| 262 |
-
matched: list[bool] = [False] * len(gt_items)
|
| 263 |
-
for gi, gt_item in enumerate(gt_items):
|
| 264 |
-
gt_form = form_extractor(gt_item)
|
| 265 |
-
for hi, hyp_item in enumerate(hyp_items):
|
| 266 |
-
if hyp_used[hi]:
|
| 267 |
-
continue
|
| 268 |
-
if form_extractor(hyp_item) == gt_form:
|
| 269 |
-
hyp_used[hi] = True
|
| 270 |
-
matched[gi] = True
|
| 271 |
-
n_strict += 1
|
| 272 |
-
break
|
| 273 |
-
# Deuxième passe : matchs sur valeur (forme différente)
|
| 274 |
-
for gi, gt_item in enumerate(gt_items):
|
| 275 |
-
if matched[gi]:
|
| 276 |
-
n_value += 1 # strict implique value
|
| 277 |
-
continue
|
| 278 |
-
gt_val = value_extractor(gt_item)
|
| 279 |
-
for hi, hyp_item in enumerate(hyp_items):
|
| 280 |
-
if hyp_used[hi]:
|
| 281 |
-
continue
|
| 282 |
-
if value_extractor(hyp_item) == gt_val:
|
| 283 |
-
hyp_used[hi] = True
|
| 284 |
-
matched[gi] = True
|
| 285 |
-
n_value += 1
|
| 286 |
-
break
|
| 287 |
-
if not matched[gi]:
|
| 288 |
-
lost.append(form_extractor(gt_item))
|
| 289 |
-
n_total = len(gt_items)
|
| 290 |
-
return {
|
| 291 |
-
"n_total": n_total,
|
| 292 |
-
"strict": n_strict,
|
| 293 |
-
"value": n_value,
|
| 294 |
-
"strict_score": n_strict / n_total if n_total else 0.0,
|
| 295 |
-
"value_score": n_value / n_total if n_total else 0.0,
|
| 296 |
-
"lost_items": lost,
|
| 297 |
-
}
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
def compute_numerical_sequence_metrics(
|
| 301 |
-
reference: Optional[str],
|
| 302 |
-
hypothesis: Optional[str],
|
| 303 |
-
) -> dict:
|
| 304 |
-
"""Calcule la précision sur séquences numériques.
|
| 305 |
-
|
| 306 |
-
Returns
|
| 307 |
-
-------
|
| 308 |
-
dict
|
| 309 |
-
Voir docstring du module. Si ``reference`` est vide
|
| 310 |
-
ou ne contient aucune séquence détectée, retourne
|
| 311 |
-
``{n_total: 0, ...}`` avec scores à 0 (pas None).
|
| 312 |
-
"""
|
| 313 |
-
ref = reference or ""
|
| 314 |
-
hyp = hypothesis or ""
|
| 315 |
-
|
| 316 |
-
# Spécifications par catégorie : (gt_items, hyp_items,
|
| 317 |
-
# extractor de forme, extractor de valeur).
|
| 318 |
-
specs: dict[str, dict] = {}
|
| 319 |
-
# year : (form="1789", value=1789)
|
| 320 |
-
specs["year"] = {
|
| 321 |
-
"gt": _detect_years(ref),
|
| 322 |
-
"hyp": _detect_years(hyp),
|
| 323 |
-
"form": lambda it: it[0],
|
| 324 |
-
"value": lambda it: it[1],
|
| 325 |
-
}
|
| 326 |
-
# roman : (form="MDCLXVIII", value=1668)
|
| 327 |
-
specs["roman"] = {
|
| 328 |
-
"gt": _detect_romans_with_values(ref),
|
| 329 |
-
"hyp": _detect_romans_with_values(hyp),
|
| 330 |
-
"form": lambda it: it[0],
|
| 331 |
-
"value": lambda it: it[1],
|
| 332 |
-
}
|
| 333 |
-
# foliation : (form="f. 12r", value="12r")
|
| 334 |
-
specs["foliation"] = {
|
| 335 |
-
"gt": _detect_foliations(ref),
|
| 336 |
-
"hyp": _detect_foliations(hyp),
|
| 337 |
-
"form": lambda it: it[0],
|
| 338 |
-
"value": lambda it: it[1],
|
| 339 |
-
}
|
| 340 |
-
# currency : (form="12 livres", value=("12", "livre"))
|
| 341 |
-
specs["currency"] = {
|
| 342 |
-
"gt": _detect_currencies(ref),
|
| 343 |
-
"hyp": _detect_currencies(hyp),
|
| 344 |
-
"form": lambda it: it[0],
|
| 345 |
-
"value": lambda it: it[1],
|
| 346 |
-
}
|
| 347 |
-
# regnal : (form="an III", value=3)
|
| 348 |
-
specs["regnal"] = {
|
| 349 |
-
"gt": _detect_regnal(ref),
|
| 350 |
-
"hyp": _detect_regnal(hyp),
|
| 351 |
-
"form": lambda it: it[0],
|
| 352 |
-
"value": lambda it: it[1],
|
| 353 |
-
}
|
| 354 |
-
|
| 355 |
-
per_category: dict[str, dict] = {}
|
| 356 |
-
total = 0
|
| 357 |
-
total_strict = 0
|
| 358 |
-
total_value = 0
|
| 359 |
-
for cat, spec in specs.items():
|
| 360 |
-
breakdown = _classify_per_category(
|
| 361 |
-
spec["gt"], spec["hyp"],
|
| 362 |
-
form_extractor=spec["form"],
|
| 363 |
-
value_extractor=spec["value"],
|
| 364 |
-
)
|
| 365 |
-
per_category[cat] = breakdown
|
| 366 |
-
total += breakdown["n_total"]
|
| 367 |
-
total_strict += breakdown["strict"]
|
| 368 |
-
total_value += breakdown["value"]
|
| 369 |
-
|
| 370 |
-
return {
|
| 371 |
-
"n_total": total,
|
| 372 |
-
"global_strict_score": (
|
| 373 |
-
total_strict / total if total else 0.0
|
| 374 |
-
),
|
| 375 |
-
"global_value_score": (
|
| 376 |
-
total_value / total if total else 0.0
|
| 377 |
-
),
|
| 378 |
-
"per_category": per_category,
|
| 379 |
-
}
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 383 |
-
# Enregistrement registre typé
|
| 384 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
@register_metric(
|
| 388 |
-
name="numerical_sequence_strict_score",
|
| 389 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 390 |
-
description=(
|
| 391 |
-
"Précision sur séquences numériques en mode strict (forme "
|
| 392 |
-
"préservée). Couvre années arabes, numéraux romains, "
|
| 393 |
-
"foliotation, montants Ancien Régime, années régnales."
|
| 394 |
-
),
|
| 395 |
)
|
| 396 |
-
def numerical_sequence_strict_score(reference: str, hypothesis: str) -> float:
|
| 397 |
-
return compute_numerical_sequence_metrics(
|
| 398 |
-
reference, hypothesis,
|
| 399 |
-
)["global_strict_score"]
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
@register_metric(
|
| 403 |
-
name="numerical_sequence_value_score",
|
| 404 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 405 |
-
description=(
|
| 406 |
-
"Précision sur séquences numériques en mode valeur "
|
| 407 |
-
"(la valeur est préservée même si la forme diffère, "
|
| 408 |
-
"ex. XIV → 14)."
|
| 409 |
-
),
|
| 410 |
-
)
|
| 411 |
-
def numerical_sequence_value_score(reference: str, hypothesis: str) -> float:
|
| 412 |
-
return compute_numerical_sequence_metrics(
|
| 413 |
-
reference, hypothesis,
|
| 414 |
-
)["global_value_score"]
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
__all__ = [
|
| 418 |
-
"CATEGORIES",
|
| 419 |
-
"compute_numerical_sequence_metrics",
|
| 420 |
-
"numerical_sequence_strict_score",
|
| 421 |
-
"numerical_sequence_value_score",
|
| 422 |
-
]
|
|
|
|
| 1 |
+
"""``picarones.measurements.numerical_sequences`` — shim re-export (déprécié, suppression 2.0).
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.evaluation.metrics.numerical_sequences`.
|
| 4 |
+
Phase 5.C.batch7 du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.evaluation.metrics.numerical_sequences import * # noqa: F401, F403
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.measurements.numerical_sequences is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.evaluation.metrics.numerical_sequences instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -18,7 +18,7 @@ from __future__ import annotations
|
|
| 18 |
import logging
|
| 19 |
from typing import Iterable, Optional
|
| 20 |
|
| 21 |
-
from picarones.
|
| 22 |
CATEGORIES,
|
| 23 |
compute_numerical_sequence_metrics,
|
| 24 |
)
|
|
|
|
| 18 |
import logging
|
| 19 |
from typing import Iterable, Optional
|
| 20 |
|
| 21 |
+
from picarones.evaluation.metrics.numerical_sequences import (
|
| 22 |
CATEGORIES,
|
| 23 |
compute_numerical_sequence_metrics,
|
| 24 |
)
|
|
@@ -34,7 +34,7 @@ from picarones.measurements.abbreviations import compute_abbreviation_metrics
|
|
| 34 |
from picarones.measurements.early_modern_typography import compute_early_modern_metrics
|
| 35 |
from picarones.measurements.modern_archives import compute_modern_archives_metrics
|
| 36 |
from picarones.measurements.mufi import compute_mufi_coverage
|
| 37 |
-
from picarones.
|
| 38 |
from picarones.measurements.unicode_blocks import compute_unicode_block_accuracy
|
| 39 |
|
| 40 |
logger = logging.getLogger(__name__)
|
|
@@ -296,7 +296,7 @@ def _aggregate_modern_archives(per_doc: list[dict]) -> dict:
|
|
| 296 |
|
| 297 |
|
| 298 |
def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
|
| 299 |
-
from picarones.
|
| 300 |
|
| 301 |
n_total = 0
|
| 302 |
per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
|
|
|
|
| 34 |
from picarones.measurements.early_modern_typography import compute_early_modern_metrics
|
| 35 |
from picarones.measurements.modern_archives import compute_modern_archives_metrics
|
| 36 |
from picarones.measurements.mufi import compute_mufi_coverage
|
| 37 |
+
from picarones.evaluation.metrics.roman_numerals import compute_roman_numeral_metrics
|
| 38 |
from picarones.measurements.unicode_blocks import compute_unicode_block_accuracy
|
| 39 |
|
| 40 |
logger = logging.getLogger(__name__)
|
|
|
|
| 296 |
|
| 297 |
|
| 298 |
def _aggregate_roman_numerals(per_doc: list[dict]) -> dict:
|
| 299 |
+
from picarones.evaluation.metrics.roman_numerals import ALL_STATUSES, VALUE_PRESERVING_STATUSES
|
| 300 |
|
| 301 |
n_total = 0
|
| 302 |
per_status: dict[str, int] = {s: 0 for s in ALL_STATUSES}
|
|
@@ -1,367 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
-
(axe B).
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
document ; ce module fournit l'orchestration sur un **corpus
|
| 7 |
-
complet** et l'agrégation des résultats par étape.
|
| 8 |
-
|
| 9 |
-
Philosophie inchangée
|
| 10 |
-
---------------------
|
| 11 |
-
Picarones reste un **banc d'essai**. Aucun module métier n'est
|
| 12 |
-
fourni — l'utilisateur amène ses propres ``BaseModule`` (Sprint 33).
|
| 13 |
-
Cette infrastructure se contente d'orchestrer leur exécution sur un
|
| 14 |
-
corpus, de mesurer le temps, de capturer les erreurs gracieusement,
|
| 15 |
-
et d'agréger les métriques calculées aux jonctions GT-vs-sortie.
|
| 16 |
-
|
| 17 |
-
Périmètre Sprint 64
|
| 18 |
-
-------------------
|
| 19 |
-
Inclus :
|
| 20 |
-
|
| 21 |
-
- ``run_pipeline_benchmark(spec, corpus, initial_inputs_factory)``
|
| 22 |
-
qui itère séquentiellement sur les documents.
|
| 23 |
-
- Agrégation par étape : ``StepAggregate`` avec n_succeeded /
|
| 24 |
-
n_failed, durées (total / mean / median), failing_doc_ids,
|
| 25 |
-
métriques agrégées par type d'artefact (mean / median sur les
|
| 26 |
-
métriques numériques uniquement), breakdown des types d'erreur.
|
| 27 |
-
- ``PipelineBenchmarkResult`` : conteneur global avec liste des
|
| 28 |
-
``PipelineResult`` par doc + liste des ``StepAggregate``.
|
| 29 |
-
- Helper ``default_initial_inputs`` qui couvre le cas standard
|
| 30 |
-
``IMAGE`` depuis ``Document.image_path``.
|
| 31 |
-
|
| 32 |
-
Reporté à des sprints suivants :
|
| 33 |
-
|
| 34 |
-
- Comparaison de N pipelines sur le même corpus (Sprint 65).
|
| 35 |
-
- DAG branchant non séquentiel (Sprint 66).
|
| 36 |
-
- Vue HTML dédiée aux pipelines composées (Sprint 67).
|
| 37 |
-
- Parallélisation inter-documents (à arbitrer selon les besoins).
|
| 38 |
"""
|
| 39 |
|
| 40 |
from __future__ import annotations
|
| 41 |
|
| 42 |
-
import
|
| 43 |
-
import statistics
|
| 44 |
-
import time
|
| 45 |
-
from dataclasses import dataclass, field
|
| 46 |
-
from typing import Any, Callable, Optional
|
| 47 |
-
|
| 48 |
-
from picarones.evaluation.corpus import Corpus, Document
|
| 49 |
-
from picarones.domain.artifacts import ArtifactType
|
| 50 |
-
from picarones.core.pipeline import (
|
| 51 |
-
PipelineResult,
|
| 52 |
-
PipelineRunner,
|
| 53 |
-
PipelineSpec,
|
| 54 |
-
)
|
| 55 |
-
|
| 56 |
-
logger = logging.getLogger(__name__)
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 60 |
-
# Helpers : factory d'entrées initiales
|
| 61 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 62 |
-
|
| 63 |
-
InitialInputsFactory = Callable[[Document], dict[ArtifactType, Any]]
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
def default_initial_inputs(document: Document) -> dict[ArtifactType, Any]:
|
| 67 |
-
"""Factory d'entrées initiales par défaut : couvre le cas
|
| 68 |
-
« la pipeline démarre par un module qui consomme l'image ».
|
| 69 |
-
|
| 70 |
-
Retourne ``{ArtifactType.IMAGE: document.image_path}`` si
|
| 71 |
-
``image_path`` est présent, sinon dict vide (la première étape
|
| 72 |
-
devra alors signaler « entrée manquante »).
|
| 73 |
-
"""
|
| 74 |
-
if document.image_path:
|
| 75 |
-
return {ArtifactType.IMAGE: document.image_path}
|
| 76 |
-
return {}
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 80 |
-
# Agrégats
|
| 81 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
@dataclass
|
| 85 |
-
class StepAggregate:
|
| 86 |
-
"""Agrégat des résultats d'une étape sur tout le corpus.
|
| 87 |
-
|
| 88 |
-
Champs
|
| 89 |
-
------
|
| 90 |
-
step_name:
|
| 91 |
-
Nom de l'étape (cf. ``PipelineStep.name``).
|
| 92 |
-
n_docs:
|
| 93 |
-
Nombre de documents pour lesquels l'étape a été tentée.
|
| 94 |
-
n_succeeded:
|
| 95 |
-
Nombre de documents pour lesquels l'étape s'est terminée
|
| 96 |
-
sans erreur (``StepResult.error is None``).
|
| 97 |
-
n_failed:
|
| 98 |
-
Nombre de documents pour lesquels l'étape a renvoyé une
|
| 99 |
-
erreur.
|
| 100 |
-
duration_seconds_total / mean / median:
|
| 101 |
-
Statistiques de durée sur les **étapes ayant réussi**
|
| 102 |
-
uniquement (les étapes en erreur peuvent avoir une durée
|
| 103 |
-
artificielle).
|
| 104 |
-
failing_doc_ids:
|
| 105 |
-
Liste des ``doc_id`` pour lesquels cette étape a échoué.
|
| 106 |
-
junction_metrics:
|
| 107 |
-
``{artifact_type_value: {metric_name: {"mean": float,
|
| 108 |
-
"median": float, "n": int}}}`` — agrégé sur les documents
|
| 109 |
-
où la métrique a été calculée (n peut différer de
|
| 110 |
-
``n_succeeded`` si la GT du type n'est pas portée par tous
|
| 111 |
-
les docs).
|
| 112 |
-
error_breakdown:
|
| 113 |
-
``{type_d_erreur: count}`` où ``type_d_erreur`` est extrait
|
| 114 |
-
en heuristique depuis le message (``"missing_input"``,
|
| 115 |
-
``"raised_exception"``, ``"missing_output"``,
|
| 116 |
-
``"other"``).
|
| 117 |
-
"""
|
| 118 |
-
|
| 119 |
-
step_name: str
|
| 120 |
-
n_docs: int = 0
|
| 121 |
-
n_succeeded: int = 0
|
| 122 |
-
n_failed: int = 0
|
| 123 |
-
duration_seconds_total: float = 0.0
|
| 124 |
-
duration_seconds_mean: float = 0.0
|
| 125 |
-
duration_seconds_median: float = 0.0
|
| 126 |
-
failing_doc_ids: list[str] = field(default_factory=list)
|
| 127 |
-
junction_metrics: dict[str, dict[str, dict[str, float]]] = field(
|
| 128 |
-
default_factory=dict,
|
| 129 |
-
)
|
| 130 |
-
error_breakdown: dict[str, int] = field(default_factory=dict)
|
| 131 |
-
|
| 132 |
-
@property
|
| 133 |
-
def success_rate(self) -> float:
|
| 134 |
-
if self.n_docs == 0:
|
| 135 |
-
return 0.0
|
| 136 |
-
return self.n_succeeded / self.n_docs
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
@dataclass
|
| 140 |
-
class PipelineBenchmarkResult:
|
| 141 |
-
"""Résultat d'un benchmark de pipeline sur un corpus complet.
|
| 142 |
-
|
| 143 |
-
On capture la durée totale, les résultats par document
|
| 144 |
-
(utiles pour le rapport HTML par-doc des sprints suivants), et
|
| 145 |
-
l'agrégation par étape.
|
| 146 |
-
"""
|
| 147 |
-
|
| 148 |
-
pipeline_name: str
|
| 149 |
-
corpus_name: str
|
| 150 |
-
n_docs: int = 0
|
| 151 |
-
per_doc_results: list[PipelineResult] = field(default_factory=list)
|
| 152 |
-
per_step_aggregates: list[StepAggregate] = field(default_factory=list)
|
| 153 |
-
total_duration_seconds: float = 0.0
|
| 154 |
-
|
| 155 |
-
@property
|
| 156 |
-
def n_pipelines_succeeded(self) -> int:
|
| 157 |
-
return sum(1 for r in self.per_doc_results if r.succeeded)
|
| 158 |
-
|
| 159 |
-
@property
|
| 160 |
-
def n_pipelines_failed(self) -> int:
|
| 161 |
-
return sum(1 for r in self.per_doc_results if not r.succeeded)
|
| 162 |
|
| 163 |
-
|
| 164 |
-
for agg in self.per_step_aggregates:
|
| 165 |
-
if agg.step_name == step_name:
|
| 166 |
-
return agg
|
| 167 |
-
return None
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
_ERROR_PATTERNS: tuple[tuple[str, str], ...] = (
|
| 176 |
-
("entrée manquante", "missing_input"),
|
| 177 |
-
("sortie manquante", "missing_output"),
|
| 178 |
-
("Error", "raised_exception"), # RuntimeError, ValueError…
|
| 179 |
)
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def _classify_error(message: str) -> str:
|
| 183 |
-
"""Heuristique simple pour catégoriser une erreur d'étape.
|
| 184 |
-
|
| 185 |
-
On regarde des marqueurs lexicaux dans le message (les messages
|
| 186 |
-
sont produits par ``pipeline_runner._run_step`` qui les contrôle
|
| 187 |
-
entièrement, donc cette heuristique est stable).
|
| 188 |
-
"""
|
| 189 |
-
if not message:
|
| 190 |
-
return "other"
|
| 191 |
-
for pattern, label in _ERROR_PATTERNS:
|
| 192 |
-
if pattern in message:
|
| 193 |
-
return label
|
| 194 |
-
return "other"
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 198 |
-
# Agrégation
|
| 199 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
def _aggregate_step(
|
| 203 |
-
step_name: str, per_doc: list[tuple[str, Any]],
|
| 204 |
-
) -> StepAggregate:
|
| 205 |
-
"""Construit le ``StepAggregate`` pour une étape donnée.
|
| 206 |
-
|
| 207 |
-
``per_doc`` est une liste de tuples ``(doc_id, step_result)`` où
|
| 208 |
-
``step_result`` peut être ``None`` (cas où la pipeline a été
|
| 209 |
-
arrêtée en amont avant cette étape) ou un ``StepResult``.
|
| 210 |
-
"""
|
| 211 |
-
agg = StepAggregate(step_name=step_name)
|
| 212 |
-
durations_succeeded: list[float] = []
|
| 213 |
-
metrics_by_type: dict[str, dict[str, list[float]]] = {}
|
| 214 |
-
|
| 215 |
-
for doc_id, sr in per_doc:
|
| 216 |
-
if sr is None:
|
| 217 |
-
# L'étape n'a même pas été exécutée (validation amont
|
| 218 |
-
# invalide, ou exécutée n'a pas atteint l'index — ne se
|
| 219 |
-
# produit pas en séquentiel mais peut arriver avec un
|
| 220 |
-
# DAG plus tard). On compte ce cas comme échec
|
| 221 |
-
# explicite avec un type dédié.
|
| 222 |
-
agg.n_docs += 1
|
| 223 |
-
agg.n_failed += 1
|
| 224 |
-
agg.failing_doc_ids.append(doc_id)
|
| 225 |
-
agg.error_breakdown["pipeline_aborted"] = (
|
| 226 |
-
agg.error_breakdown.get("pipeline_aborted", 0) + 1
|
| 227 |
-
)
|
| 228 |
-
continue
|
| 229 |
-
agg.n_docs += 1
|
| 230 |
-
if sr.error is None:
|
| 231 |
-
agg.n_succeeded += 1
|
| 232 |
-
durations_succeeded.append(sr.duration_seconds)
|
| 233 |
-
# Collecte des métriques pour agrégation moyenne/médiane
|
| 234 |
-
for at_value, metrics in sr.junction_metrics.items():
|
| 235 |
-
slot = metrics_by_type.setdefault(at_value, {})
|
| 236 |
-
for mname, mvalue in metrics.items():
|
| 237 |
-
if isinstance(mvalue, (int, float)) and not isinstance(
|
| 238 |
-
mvalue, bool,
|
| 239 |
-
):
|
| 240 |
-
slot.setdefault(mname, []).append(float(mvalue))
|
| 241 |
-
else:
|
| 242 |
-
agg.n_failed += 1
|
| 243 |
-
agg.failing_doc_ids.append(doc_id)
|
| 244 |
-
label = _classify_error(sr.error)
|
| 245 |
-
agg.error_breakdown[label] = (
|
| 246 |
-
agg.error_breakdown.get(label, 0) + 1
|
| 247 |
-
)
|
| 248 |
-
|
| 249 |
-
if durations_succeeded:
|
| 250 |
-
agg.duration_seconds_total = sum(durations_succeeded)
|
| 251 |
-
agg.duration_seconds_mean = statistics.fmean(durations_succeeded)
|
| 252 |
-
agg.duration_seconds_median = statistics.median(durations_succeeded)
|
| 253 |
-
|
| 254 |
-
for at_value, metrics in metrics_by_type.items():
|
| 255 |
-
agg.junction_metrics[at_value] = {
|
| 256 |
-
mname: {
|
| 257 |
-
"mean": statistics.fmean(values),
|
| 258 |
-
"median": statistics.median(values),
|
| 259 |
-
"n": len(values),
|
| 260 |
-
}
|
| 261 |
-
for mname, values in metrics.items()
|
| 262 |
-
}
|
| 263 |
-
# Phase 4-bis : double-clé legacy/canonique pour rétrocompat.
|
| 264 |
-
from picarones.domain.artifacts import expand_legacy_keys
|
| 265 |
-
expand_legacy_keys(agg.junction_metrics)
|
| 266 |
-
return agg
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 270 |
-
# Orchestrateur principal
|
| 271 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
def run_pipeline_benchmark(
|
| 275 |
-
spec: PipelineSpec,
|
| 276 |
-
corpus: Corpus,
|
| 277 |
-
initial_inputs_factory: InitialInputsFactory = default_initial_inputs,
|
| 278 |
-
) -> PipelineBenchmarkResult:
|
| 279 |
-
"""Exécute ``spec`` sur tous les documents de ``corpus``.
|
| 280 |
-
|
| 281 |
-
Parameters
|
| 282 |
-
----------
|
| 283 |
-
spec:
|
| 284 |
-
Spécification de la pipeline composée. Toutes les étapes
|
| 285 |
-
sont des ``BaseModule`` fournis par l'utilisateur.
|
| 286 |
-
corpus:
|
| 287 |
-
Corpus chargé via ``Corpus.from_directory`` ou équivalent.
|
| 288 |
-
initial_inputs_factory:
|
| 289 |
-
Fonction qui produit, pour chaque document, les artefacts
|
| 290 |
-
d'entrée de la pipeline. Par défaut : ``IMAGE`` depuis
|
| 291 |
-
``document.image_path``. L'utilisateur peut fournir une
|
| 292 |
-
factory personnalisée pour brancher d'autres sources
|
| 293 |
-
(par exemple ``ALTO`` pré-existant pour évaluer un
|
| 294 |
-
pipeline qui démarre par un re-segmenteur).
|
| 295 |
-
|
| 296 |
-
Returns
|
| 297 |
-
-------
|
| 298 |
-
PipelineBenchmarkResult
|
| 299 |
-
Résultat global avec ``per_doc_results``,
|
| 300 |
-
``per_step_aggregates``, durée totale.
|
| 301 |
-
|
| 302 |
-
Comportement
|
| 303 |
-
------------
|
| 304 |
-
L'orchestration est **séquentielle** par document. Pour chaque
|
| 305 |
-
document, ``PipelineRunner.run`` est appelé ; quel que soit le
|
| 306 |
-
résultat (réussi, partiellement échoué, totalement invalide),
|
| 307 |
-
le résultat est ajouté à ``per_doc_results`` et le benchmark
|
| 308 |
-
continue avec le document suivant.
|
| 309 |
-
|
| 310 |
-
Si la spec est statiquement invalide (cf.
|
| 311 |
-
``PipelineSpec.validate``), tous les documents auront un
|
| 312 |
-
``PipelineResult.error`` non vide et aucune étape ne sera
|
| 313 |
-
exécutée — le résultat reste cohérent.
|
| 314 |
-
"""
|
| 315 |
-
result = PipelineBenchmarkResult(
|
| 316 |
-
pipeline_name=spec.name, corpus_name=corpus.name,
|
| 317 |
-
)
|
| 318 |
-
documents = list(corpus.documents)
|
| 319 |
-
result.n_docs = len(documents)
|
| 320 |
-
|
| 321 |
-
benchmark_t0 = time.monotonic()
|
| 322 |
-
for doc in documents:
|
| 323 |
-
try:
|
| 324 |
-
initial = initial_inputs_factory(doc)
|
| 325 |
-
except Exception as exc: # noqa: BLE001
|
| 326 |
-
logger.warning(
|
| 327 |
-
"[pipeline_benchmark] factory a levé sur %s : %s",
|
| 328 |
-
doc.doc_id, exc,
|
| 329 |
-
)
|
| 330 |
-
# On crée un PipelineResult portant l'erreur factory
|
| 331 |
-
failed = PipelineResult(
|
| 332 |
-
pipeline_name=spec.name, doc_id=doc.doc_id,
|
| 333 |
-
error=f"initial_inputs_factory: {type(exc).__name__}: {exc}",
|
| 334 |
-
)
|
| 335 |
-
result.per_doc_results.append(failed)
|
| 336 |
-
continue
|
| 337 |
-
per_doc = PipelineRunner.run(spec, doc, initial)
|
| 338 |
-
result.per_doc_results.append(per_doc)
|
| 339 |
-
result.total_duration_seconds = time.monotonic() - benchmark_t0
|
| 340 |
-
|
| 341 |
-
# Agrégation par étape
|
| 342 |
-
step_names = [step.name for step in spec.steps]
|
| 343 |
-
for idx, step_name in enumerate(step_names):
|
| 344 |
-
per_doc_step: list[tuple[str, Any]] = []
|
| 345 |
-
for pr in result.per_doc_results:
|
| 346 |
-
if idx < len(pr.steps):
|
| 347 |
-
per_doc_step.append((pr.doc_id, pr.steps[idx]))
|
| 348 |
-
else:
|
| 349 |
-
# Pipeline a été arrêtée en amont : aucune étape de
|
| 350 |
-
# cet index n'existe. On compte ça comme une
|
| 351 |
-
# absence d'étape (cf. ``_aggregate_step`` qui gère
|
| 352 |
-
# le ``None``).
|
| 353 |
-
per_doc_step.append((pr.doc_id, None))
|
| 354 |
-
result.per_step_aggregates.append(
|
| 355 |
-
_aggregate_step(step_name, per_doc_step),
|
| 356 |
-
)
|
| 357 |
-
|
| 358 |
-
return result
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
__all__ = [
|
| 362 |
-
"InitialInputsFactory",
|
| 363 |
-
"PipelineBenchmarkResult",
|
| 364 |
-
"StepAggregate",
|
| 365 |
-
"default_initial_inputs",
|
| 366 |
-
"run_pipeline_benchmark",
|
| 367 |
-
]
|
|
|
|
| 1 |
+
"""``picarones.measurements.pipeline_benchmark`` — shim re-export (déprécié, suppression 2.0).
|
|
|
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.evaluation.pipeline_benchmark`.
|
| 4 |
+
Phase 5.C.batch7 du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.evaluation.pipeline_benchmark import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.measurements.pipeline_benchmark is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.evaluation.pipeline_benchmark instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,301 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
l'orchestration corpus-wide (Sprint 64) permettent d'évaluer **une**
|
| 6 |
-
pipeline composée ; ce sprint répond à la question typique BnF :
|
| 7 |
-
|
| 8 |
-
« OCR seul vs OCR+correcteur A vs OCR+correcteur B :
|
| 9 |
-
laquelle est la meilleure sur mon corpus, et de combien ? »
|
| 10 |
-
|
| 11 |
-
Philosophie inchangée
|
| 12 |
-
---------------------
|
| 13 |
-
Picarones reste un **banc d'essai** — on juge des pipelines tierces
|
| 14 |
-
sur le **même corpus** avec la **même GT**, en exposant des chiffres
|
| 15 |
-
bruts comparatifs. Aucun verdict imposé : le chercheur lit le
|
| 16 |
-
ranking et la table de gain et conclut selon ses critères.
|
| 17 |
-
|
| 18 |
-
Périmètre Sprint 65
|
| 19 |
-
-------------------
|
| 20 |
-
Inclus :
|
| 21 |
-
|
| 22 |
-
- ``compare_pipelines(specs, corpus, factories=None)`` qui exécute
|
| 23 |
-
séquentiellement N pipelines sur le même corpus.
|
| 24 |
-
- ``PipelineComparisonResult`` : conteneur avec
|
| 25 |
-
``per_pipeline: dict[name → PipelineBenchmarkResult]``,
|
| 26 |
-
``ranking_by_final_metric(artifact_type, metric_name,
|
| 27 |
-
higher_is_better)`` qui retourne ``[(pipeline_name, score), ...]``
|
| 28 |
-
trié, et ``gain_table(artifact_type, metric_name,
|
| 29 |
-
baseline_pipeline)`` qui retourne pour chaque pipeline le
|
| 30 |
-
``{absolute, relative}`` vs baseline.
|
| 31 |
-
- ``factories``: dict ``{pipeline_name: InitialInputsFactory}`` pour
|
| 32 |
-
personnaliser les entrées initiales par pipeline (utile pour
|
| 33 |
-
comparer une pipeline qui démarre par IMAGE et une qui démarre
|
| 34 |
-
par TEXT).
|
| 35 |
-
- Garde-fou : noms de pipelines uniques exigés.
|
| 36 |
-
|
| 37 |
-
Reporté à des sprints suivants :
|
| 38 |
-
|
| 39 |
-
- DAG branchant non séquentiel (Sprint 66).
|
| 40 |
-
- Vue HTML dédiée à la comparaison de pipelines (Sprint 67+).
|
| 41 |
-
- Tests statistiques (Wilcoxon, Friedman, Nemenyi) sur les
|
| 42 |
-
pipelines composées — déjà disponibles côté OCR (Sprint 18) ;
|
| 43 |
-
l'application au cadre pipeline arrive plus tard.
|
| 44 |
"""
|
| 45 |
|
| 46 |
from __future__ import annotations
|
| 47 |
|
| 48 |
-
import
|
| 49 |
-
import time
|
| 50 |
-
from dataclasses import dataclass, field
|
| 51 |
-
from typing import Optional
|
| 52 |
-
|
| 53 |
-
from picarones.evaluation.corpus import Corpus
|
| 54 |
-
from picarones.domain.artifacts import ArtifactType
|
| 55 |
-
from picarones.measurements.pipeline_benchmark import (
|
| 56 |
-
InitialInputsFactory,
|
| 57 |
-
PipelineBenchmarkResult,
|
| 58 |
-
default_initial_inputs,
|
| 59 |
-
run_pipeline_benchmark,
|
| 60 |
-
)
|
| 61 |
-
from picarones.core.pipeline import PipelineSpec
|
| 62 |
-
|
| 63 |
-
logger = logging.getLogger(__name__)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 67 |
-
# Conteneur de résultats
|
| 68 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
@dataclass
|
| 72 |
-
class PipelineComparisonResult:
|
| 73 |
-
"""Résultat de la comparaison de N pipelines sur un corpus.
|
| 74 |
-
|
| 75 |
-
Champs
|
| 76 |
-
------
|
| 77 |
-
corpus_name:
|
| 78 |
-
Nom du corpus (commun à toutes les pipelines comparées).
|
| 79 |
-
n_docs:
|
| 80 |
-
Nombre de documents du corpus.
|
| 81 |
-
per_pipeline:
|
| 82 |
-
Map ``{pipeline_name: PipelineBenchmarkResult}``. L'ordre
|
| 83 |
-
d'insertion suit l'ordre des ``specs`` passées à
|
| 84 |
-
``compare_pipelines`` ; on s'appuie sur le ``dict`` ordonné
|
| 85 |
-
de Python 3.7+.
|
| 86 |
-
total_duration_seconds:
|
| 87 |
-
Durée totale de la comparaison (sommes des durées par
|
| 88 |
-
pipeline + petit overhead).
|
| 89 |
-
"""
|
| 90 |
-
|
| 91 |
-
corpus_name: str
|
| 92 |
-
n_docs: int = 0
|
| 93 |
-
per_pipeline: dict[str, PipelineBenchmarkResult] = field(
|
| 94 |
-
default_factory=dict,
|
| 95 |
-
)
|
| 96 |
-
total_duration_seconds: float = 0.0
|
| 97 |
-
|
| 98 |
-
def pipeline_names(self) -> list[str]:
|
| 99 |
-
"""Retourne la liste des noms de pipelines dans leur ordre
|
| 100 |
-
d'insertion (= ordre de la comparaison initiale)."""
|
| 101 |
-
return list(self.per_pipeline.keys())
|
| 102 |
-
|
| 103 |
-
def _final_metric_value(
|
| 104 |
-
self,
|
| 105 |
-
pipeline_name: str,
|
| 106 |
-
artifact_type: ArtifactType,
|
| 107 |
-
metric_name: str,
|
| 108 |
-
) -> Optional[float]:
|
| 109 |
-
"""Retourne le ``mean`` de la métrique demandée à la
|
| 110 |
-
**dernière étape** de la pipeline qui a produit
|
| 111 |
-
``artifact_type`` (avec succès sur ≥ 1 doc), ou ``None``
|
| 112 |
-
si la métrique n'est pas disponible.
|
| 113 |
-
|
| 114 |
-
Cohérent avec ``PipelineResult.junction_metrics_for`` du
|
| 115 |
-
Sprint 63 mais au niveau corpus-wide.
|
| 116 |
-
"""
|
| 117 |
-
bench = self.per_pipeline.get(pipeline_name)
|
| 118 |
-
if bench is None:
|
| 119 |
-
return None
|
| 120 |
-
from picarones.domain.artifacts import LEGACY_VALUE_ALIASES
|
| 121 |
-
legacy_alias = LEGACY_VALUE_ALIASES.get(artifact_type.value)
|
| 122 |
-
for agg in reversed(bench.per_step_aggregates):
|
| 123 |
-
type_metrics = agg.junction_metrics.get(artifact_type.value)
|
| 124 |
-
if not type_metrics and legacy_alias is not None:
|
| 125 |
-
# Phase 4-bis : un caller (typiquement les tests
|
| 126 |
-
# ou un agrégateur tiers) peut avoir construit le
|
| 127 |
-
# dict avec la clé legacy ``"text"`` au lieu de la
|
| 128 |
-
# canonique ``"raw_text"``. expand_legacy_keys
|
| 129 |
-
# synchronise les deux côtés sur les sites
|
| 130 |
-
# d'écriture du runner — ce fallback couvre le
|
| 131 |
-
# reste.
|
| 132 |
-
type_metrics = agg.junction_metrics.get(legacy_alias)
|
| 133 |
-
if not type_metrics:
|
| 134 |
-
continue
|
| 135 |
-
stats = type_metrics.get(metric_name)
|
| 136 |
-
if stats is None:
|
| 137 |
-
continue
|
| 138 |
-
return stats["mean"]
|
| 139 |
-
return None
|
| 140 |
|
| 141 |
-
|
| 142 |
-
self,
|
| 143 |
-
artifact_type: ArtifactType,
|
| 144 |
-
metric_name: str,
|
| 145 |
-
higher_is_better: bool = False,
|
| 146 |
-
) -> list[tuple[str, Optional[float]]]:
|
| 147 |
-
"""Classe les pipelines par la valeur **finale** de
|
| 148 |
-
``metric_name`` à la jonction ``artifact_type``.
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
premier, triées selon ``higher_is_better``.
|
| 157 |
-
- Les pipelines sans valeur (métrique absente) viennent
|
| 158 |
-
en queue, dans leur ordre d'insertion.
|
| 159 |
-
"""
|
| 160 |
-
with_value: list[tuple[str, float]] = []
|
| 161 |
-
without_value: list[tuple[str, Optional[float]]] = []
|
| 162 |
-
for name in self.pipeline_names():
|
| 163 |
-
value = self._final_metric_value(name, artifact_type, metric_name)
|
| 164 |
-
if value is None:
|
| 165 |
-
without_value.append((name, None))
|
| 166 |
-
else:
|
| 167 |
-
with_value.append((name, value))
|
| 168 |
-
with_value.sort(
|
| 169 |
-
key=lambda pair: pair[1],
|
| 170 |
-
reverse=higher_is_better,
|
| 171 |
-
)
|
| 172 |
-
return [*with_value, *without_value]
|
| 173 |
-
|
| 174 |
-
def gain_table(
|
| 175 |
-
self,
|
| 176 |
-
artifact_type: ArtifactType,
|
| 177 |
-
metric_name: str,
|
| 178 |
-
baseline_pipeline: str,
|
| 179 |
-
) -> dict[str, dict[str, Optional[float]]]:
|
| 180 |
-
"""Calcule l'écart de chaque pipeline vs la baseline.
|
| 181 |
-
|
| 182 |
-
Returns
|
| 183 |
-
-------
|
| 184 |
-
dict
|
| 185 |
-
Map ``{pipeline_name: {"value", "absolute", "relative"}}``
|
| 186 |
-
où :
|
| 187 |
-
|
| 188 |
-
- ``value`` : valeur finale de la métrique pour cette
|
| 189 |
-
pipeline (``None`` si absente).
|
| 190 |
-
- ``absolute`` : ``value - baseline_value``
|
| 191 |
-
(``None`` si l'une des deux est absente).
|
| 192 |
-
- ``relative`` : ``(value - baseline_value) /
|
| 193 |
-
baseline_value`` (``None`` si baseline absente ou
|
| 194 |
-
égale à 0).
|
| 195 |
-
|
| 196 |
-
La baseline elle-même apparaît avec ``absolute == 0`` et
|
| 197 |
-
``relative == 0``.
|
| 198 |
-
"""
|
| 199 |
-
if baseline_pipeline not in self.per_pipeline:
|
| 200 |
-
raise KeyError(
|
| 201 |
-
f"baseline {baseline_pipeline!r} absente de la comparaison",
|
| 202 |
-
)
|
| 203 |
-
baseline_value = self._final_metric_value(
|
| 204 |
-
baseline_pipeline, artifact_type, metric_name,
|
| 205 |
-
)
|
| 206 |
-
out: dict[str, dict[str, Optional[float]]] = {}
|
| 207 |
-
for name in self.pipeline_names():
|
| 208 |
-
value = self._final_metric_value(
|
| 209 |
-
name, artifact_type, metric_name,
|
| 210 |
-
)
|
| 211 |
-
absolute: Optional[float]
|
| 212 |
-
relative: Optional[float]
|
| 213 |
-
if value is None or baseline_value is None:
|
| 214 |
-
absolute = None
|
| 215 |
-
relative = None
|
| 216 |
-
else:
|
| 217 |
-
absolute = value - baseline_value
|
| 218 |
-
relative = (
|
| 219 |
-
(value - baseline_value) / baseline_value
|
| 220 |
-
if baseline_value != 0 else None
|
| 221 |
-
)
|
| 222 |
-
out[name] = {
|
| 223 |
-
"value": value,
|
| 224 |
-
"absolute": absolute,
|
| 225 |
-
"relative": relative,
|
| 226 |
-
}
|
| 227 |
-
return out
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 231 |
-
# Orchestrateur
|
| 232 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
def compare_pipelines(
|
| 236 |
-
specs: list[PipelineSpec],
|
| 237 |
-
corpus: Corpus,
|
| 238 |
-
factories: Optional[dict[str, InitialInputsFactory]] = None,
|
| 239 |
-
) -> PipelineComparisonResult:
|
| 240 |
-
"""Exécute N ``PipelineSpec`` sur le **même** ``corpus``.
|
| 241 |
-
|
| 242 |
-
Parameters
|
| 243 |
-
----------
|
| 244 |
-
specs:
|
| 245 |
-
Liste de ``PipelineSpec``. Les noms de pipelines doivent
|
| 246 |
-
être uniques (sinon ``ValueError``).
|
| 247 |
-
corpus:
|
| 248 |
-
Corpus partagé entre toutes les pipelines comparées —
|
| 249 |
-
c'est le point fort du sprint : même corpus, même GT, on
|
| 250 |
-
peut comparer apple-to-apple.
|
| 251 |
-
factories:
|
| 252 |
-
Optionnel. Si fourni, dict ``{pipeline_name:
|
| 253 |
-
InitialInputsFactory}`` pour personnaliser les entrées
|
| 254 |
-
initiales par pipeline. Les pipelines absentes du dict
|
| 255 |
-
utilisent ``default_initial_inputs`` (cas standard
|
| 256 |
-
``IMAGE`` depuis ``Document.image_path``).
|
| 257 |
-
|
| 258 |
-
Returns
|
| 259 |
-
-------
|
| 260 |
-
PipelineComparisonResult
|
| 261 |
-
Conteneur avec ``per_pipeline`` indexé par nom et
|
| 262 |
-
utilitaires comparatifs (``ranking_by_final_metric``,
|
| 263 |
-
``gain_table``).
|
| 264 |
-
|
| 265 |
-
Raises
|
| 266 |
-
------
|
| 267 |
-
ValueError
|
| 268 |
-
Si deux ``PipelineSpec`` ont le même nom (impossible alors
|
| 269 |
-
de les distinguer dans le résultat).
|
| 270 |
-
"""
|
| 271 |
-
names = [s.name for s in specs]
|
| 272 |
-
if len(set(names)) != len(names):
|
| 273 |
-
seen: set[str] = set()
|
| 274 |
-
duplicates: list[str] = []
|
| 275 |
-
for n in names:
|
| 276 |
-
if n in seen:
|
| 277 |
-
duplicates.append(n)
|
| 278 |
-
seen.add(n)
|
| 279 |
-
raise ValueError(
|
| 280 |
-
f"noms de pipelines non uniques : {sorted(set(duplicates))}",
|
| 281 |
-
)
|
| 282 |
-
|
| 283 |
-
factories = factories or {}
|
| 284 |
-
result = PipelineComparisonResult(
|
| 285 |
-
corpus_name=corpus.name,
|
| 286 |
-
n_docs=len(list(corpus.documents)),
|
| 287 |
-
)
|
| 288 |
-
|
| 289 |
-
t0 = time.monotonic()
|
| 290 |
-
for spec in specs:
|
| 291 |
-
factory = factories.get(spec.name, default_initial_inputs)
|
| 292 |
-
bench = run_pipeline_benchmark(spec, corpus, factory)
|
| 293 |
-
result.per_pipeline[spec.name] = bench
|
| 294 |
-
result.total_duration_seconds = time.monotonic() - t0
|
| 295 |
-
return result
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
__all__ = [
|
| 299 |
-
"PipelineComparisonResult",
|
| 300 |
-
"compare_pipelines",
|
| 301 |
-
]
|
|
|
|
| 1 |
+
"""``picarones.measurements.pipeline_comparison`` — shim re-export (déprécié, suppression 2.0).
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.evaluation.pipeline_comparison`.
|
| 4 |
+
Phase 5.C.batch7 du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.evaluation.pipeline_comparison import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.measurements.pipeline_comparison is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.evaluation.pipeline_comparison instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
| 18 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -69,7 +69,7 @@ from typing import Any
|
|
| 69 |
|
| 70 |
from picarones.domain.artifacts import ArtifactType
|
| 71 |
from picarones.domain.module_protocol import BaseModule
|
| 72 |
-
from picarones.
|
| 73 |
|
| 74 |
logger = logging.getLogger(__name__)
|
| 75 |
|
|
|
|
| 69 |
|
| 70 |
from picarones.domain.artifacts import ArtifactType
|
| 71 |
from picarones.domain.module_protocol import BaseModule
|
| 72 |
+
from picarones.evaluation.pipeline import PipelineSpec, PipelineStep
|
| 73 |
|
| 74 |
logger = logging.getLogger(__name__)
|
| 75 |
|
|
@@ -1,478 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
Pourquoi ce module
|
| 7 |
-
------------------
|
| 8 |
-
Les numéraux romains traversent **toutes les périodes patrimoniales**
|
| 9 |
-
servies par Picarones :
|
| 10 |
-
|
| 11 |
-
- **Médiéval** : minuscules avec ``j`` final pour le dernier ``i``
|
| 12 |
-
(``ij`` = 2, ``iij`` = 3, ``viij`` = 8, ``mcclxxxij`` = 1282).
|
| 13 |
-
Convention scribale standard dans les chartes et registres.
|
| 14 |
-
- **Imprimé ancien** : majuscules (``Tome IV``, ``Chap. VII``).
|
| 15 |
-
- **Moderne** : majuscules pour les souverains (``Louis XIV``) et
|
| 16 |
-
les siècles (``XIXᵉ siècle`` — la partie exposant ᵉ est gérée
|
| 17 |
-
par le Sprint 59 ``ordinals``, ce module ne traite que la partie
|
| 18 |
-
numérale ``XIX``).
|
| 19 |
-
|
| 20 |
-
Quatre traitements possibles d'un numéral par l'OCR
|
| 21 |
-
----------------------------------------------------
|
| 22 |
-
Pour chaque numéral romain présent dans la GT, l'OCR peut :
|
| 23 |
-
|
| 24 |
-
1. **Préserver strictement** : forme exacte gardée
|
| 25 |
-
(``mcclxxxij`` → ``mcclxxxij``). Édition diplomatique idéale.
|
| 26 |
-
2. **Préserver en changeant la casse** : la valeur est intacte mais
|
| 27 |
-
la convention typographique est modifiée
|
| 28 |
-
(``xiv`` → ``XIV``). Modernisation typographique courante.
|
| 29 |
-
3. **Préserver en supprimant le ``j`` final** :
|
| 30 |
-
(``mcclxxxij`` → ``mcclxxxii``). Modernisation orthographique
|
| 31 |
-
médiévale → standard académique moderne.
|
| 32 |
-
4. **Convertir en chiffres arabes** : la valeur est préservée mais
|
| 33 |
-
le système de numération est modernisé
|
| 34 |
-
(``XIV`` → ``14``). Modernisation profonde, perte de
|
| 35 |
-
l'information typographique.
|
| 36 |
-
5. **Perdre** : aucune trace de la valeur dans l'hypothèse.
|
| 37 |
-
|
| 38 |
-
Ce module retourne un breakdown par statut pour que le chercheur
|
| 39 |
-
juge lui-même la convention adoptée par chaque moteur, **sans
|
| 40 |
-
classification automatique imposée**.
|
| 41 |
-
|
| 42 |
-
Stratégie de découpage
|
| 43 |
-
----------------------
|
| 44 |
-
Cohérente avec NER (38), Flesch (52), Reading order F1 (53),
|
| 45 |
-
Layout F1 (54), Bloc Unicode (55), Abréviations (56), MUFI (57),
|
| 46 |
-
Imprimé ancien (58), Archives modernes (59) : couche de calcul
|
| 47 |
-
pure d'abord ; câblage runner et HTML dans des sprints dédiés.
|
| 48 |
-
|
| 49 |
-
Limites documentées
|
| 50 |
-
-------------------
|
| 51 |
-
- Détection greedy par regex ``\\b[IVXLCDMivxlcdmj]+\\b`` puis
|
| 52 |
-
validation par parsing. Les faux positifs restent possibles sur
|
| 53 |
-
des mots courts (``I`` pronom anglais, ``MM`` initiales, ``LL``).
|
| 54 |
-
Le paramètre ``min_length`` permet de filtrer les single-letter.
|
| 55 |
-
- Pas de gestion des notations rares avec barre suscript pour
|
| 56 |
-
multiplier par 1000 (V̄ = 5000, X̄ = 10000) — usage très rare en
|
| 57 |
-
corpus patrimonial européen courant.
|
| 58 |
"""
|
| 59 |
|
| 60 |
from __future__ import annotations
|
| 61 |
|
| 62 |
-
import
|
| 63 |
-
import re
|
| 64 |
-
from typing import Optional
|
| 65 |
-
|
| 66 |
-
from picarones.evaluation.metric_registry import register_metric
|
| 67 |
-
from picarones.domain.artifacts import ArtifactType
|
| 68 |
-
|
| 69 |
-
logger = logging.getLogger(__name__)
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 73 |
-
# Table de conversion + parsing
|
| 74 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 75 |
-
|
| 76 |
-
ROMAN_VALUES: dict[str, int] = {
|
| 77 |
-
"I": 1, "V": 5, "X": 10,
|
| 78 |
-
"L": 50, "C": 100, "D": 500, "M": 1000,
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
# Caractères acceptés en entrée (incluant minuscules + j médiéval).
|
| 82 |
-
_ROMAN_CHARS = "IVXLCDMivxlcdmj"
|
| 83 |
-
_ROMAN_RE = re.compile(rf"\b[{_ROMAN_CHARS}]+\b")
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
def _normalize_roman(s: str) -> str:
|
| 87 |
-
"""Normalise un numéral romain : majuscule + ``j`` final → ``i``.
|
| 88 |
-
|
| 89 |
-
Les manuscrits médiévaux notent traditionnellement le dernier
|
| 90 |
-
``i`` d'une suite par ``j`` (« ij », « iij », « viij »…). On
|
| 91 |
-
convertit pour pouvoir parser comme un numéral standard.
|
| 92 |
-
"""
|
| 93 |
-
if not s:
|
| 94 |
-
return ""
|
| 95 |
-
upper = s.upper()
|
| 96 |
-
if upper.endswith("J"):
|
| 97 |
-
upper = upper[:-1] + "I"
|
| 98 |
-
return upper
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def _parse_normalized_roman(s: str) -> Optional[int]:
|
| 102 |
-
"""Parse un numéral romain **après normalisation** (majuscule,
|
| 103 |
-
sans ``j`` médiéval). Retourne ``None`` si la chaîne n'est pas
|
| 104 |
-
un numéral romain valide.
|
| 105 |
-
|
| 106 |
-
Validation : on parse en additionnant/soustrayant selon la règle
|
| 107 |
-
classique, puis on **regénère la forme standard** et on compare
|
| 108 |
-
pour rejeter les formes non canoniques (« IIII » au lieu de
|
| 109 |
-
« IV », « VV » au lieu de « X »). Cette stricte validation
|
| 110 |
-
garantit qu'on ne compte pas des séquences absurdes comme
|
| 111 |
-
« XXXX » comme un numéral.
|
| 112 |
-
|
| 113 |
-
Note : les manuscrits médiévaux utilisent fréquemment « IIII »
|
| 114 |
-
pour 4 (notation soustractive plus tardive). On accepte donc
|
| 115 |
-
aussi cette forme via une règle relâchée : tant que les valeurs
|
| 116 |
-
sont décroissantes ou suivent la règle soustractive standard,
|
| 117 |
-
on accepte.
|
| 118 |
-
"""
|
| 119 |
-
if not s or not all(c in "IVXLCDM" for c in s):
|
| 120 |
-
return None
|
| 121 |
-
# Calcul par soustraction.
|
| 122 |
-
total = 0
|
| 123 |
-
prev_value = 0
|
| 124 |
-
for ch in reversed(s):
|
| 125 |
-
v = ROMAN_VALUES[ch]
|
| 126 |
-
if v < prev_value:
|
| 127 |
-
total -= v
|
| 128 |
-
else:
|
| 129 |
-
total += v
|
| 130 |
-
prev_value = v
|
| 131 |
-
if total <= 0:
|
| 132 |
-
return None
|
| 133 |
-
# Validation relâchée : on accepte les formes médiévales (IIII,
|
| 134 |
-
# VIIII) mais on rejette les vraiment absurdes (IIIII, VVVV).
|
| 135 |
-
if not _is_plausible_roman(s):
|
| 136 |
-
return None
|
| 137 |
-
return total
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
def _is_plausible_roman(s: str) -> bool:
|
| 141 |
-
"""Validation relâchée d'un numéral romain (majuscule).
|
| 142 |
-
|
| 143 |
-
On rejette :
|
| 144 |
-
|
| 145 |
-
- 5 caractères identiques d'affilée ou plus (« IIIII », « XXXXX »).
|
| 146 |
-
- Les répétitions de V, L, D (jamais répétés en notation
|
| 147 |
-
classique : « VV », « LL », « DD »).
|
| 148 |
-
- Les paires soustractives non standard. En romain canonique,
|
| 149 |
-
seules sont valides : IV, IX, XL, XC, CD, CM. Toute autre
|
| 150 |
-
combinaison « petit avant grand » est rejetée. Cela élimine
|
| 151 |
-
les faux positifs sur des mots français comme « ici » (qui
|
| 152 |
-
formerait sinon « I + C » = 99) ou « IL » qui formerait 49.
|
| 153 |
-
"""
|
| 154 |
-
if not s:
|
| 155 |
-
return False
|
| 156 |
-
# Pas de répétitions invalides
|
| 157 |
-
for forbidden in ("VV", "LL", "DD", "IIIII", "XXXXX", "CCCCC", "MMMMMM"):
|
| 158 |
-
if forbidden in s:
|
| 159 |
-
return False
|
| 160 |
-
# Paires soustractives autorisées (toutes les autres sont rejetées)
|
| 161 |
-
legal_subtractive = {"IV", "IX", "XL", "XC", "CD", "CM"}
|
| 162 |
-
for i in range(len(s) - 1):
|
| 163 |
-
a, b = s[i], s[i + 1]
|
| 164 |
-
if ROMAN_VALUES[a] < ROMAN_VALUES[b]:
|
| 165 |
-
if (a + b) not in legal_subtractive:
|
| 166 |
-
return False
|
| 167 |
-
return True
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
def roman_to_int(s: Optional[str]) -> Optional[int]:
|
| 171 |
-
"""Convertit une chaîne en numéral romain entier. Tolère casse
|
| 172 |
-
et ``j`` médiéval final. Retourne ``None`` si invalide.
|
| 173 |
-
"""
|
| 174 |
-
if not s:
|
| 175 |
-
return None
|
| 176 |
-
return _parse_normalized_roman(_normalize_roman(s))
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def int_to_roman(n: int) -> str:
|
| 180 |
-
"""Convertit un entier en numéral romain majuscule standard.
|
| 181 |
-
|
| 182 |
-
Utilise la notation classique (IV, IX, XL, XC, CD, CM) — pas la
|
| 183 |
-
forme médiévale relâchée.
|
| 184 |
-
"""
|
| 185 |
-
if n <= 0:
|
| 186 |
-
raise ValueError("n must be positive")
|
| 187 |
-
pairs = [
|
| 188 |
-
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
|
| 189 |
-
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
|
| 190 |
-
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"),
|
| 191 |
-
(1, "I"),
|
| 192 |
-
]
|
| 193 |
-
out: list[str] = []
|
| 194 |
-
for value, symbol in pairs:
|
| 195 |
-
while n >= value:
|
| 196 |
-
out.append(symbol)
|
| 197 |
-
n -= value
|
| 198 |
-
return "".join(out)
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 202 |
-
# Détection dans le texte
|
| 203 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def detect_roman_numerals(
|
| 207 |
-
text: Optional[str],
|
| 208 |
-
*,
|
| 209 |
-
min_length: int = 1,
|
| 210 |
-
) -> list[tuple[int, str, int]]:
|
| 211 |
-
"""Retourne les numéraux romains valides dans ``text``.
|
| 212 |
-
|
| 213 |
-
Forme : ``[(start_index, numeral_string, integer_value), ...]``
|
| 214 |
-
triée par index croissant.
|
| 215 |
|
| 216 |
-
|
| 217 |
-
----------
|
| 218 |
-
text:
|
| 219 |
-
Texte à analyser.
|
| 220 |
-
min_length:
|
| 221 |
-
Longueur minimale d'un numéral retenu. Par défaut ``1``.
|
| 222 |
-
Mettre à ``2`` pour filtrer les single-letter ambigus (``I``
|
| 223 |
-
pronom, ``M`` initiale).
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
``min_length=2``.
|
| 231 |
-
"""
|
| 232 |
-
if not text:
|
| 233 |
-
return []
|
| 234 |
-
found: list[tuple[int, str, int]] = []
|
| 235 |
-
for match in _ROMAN_RE.finditer(text):
|
| 236 |
-
s = match.group(0)
|
| 237 |
-
if len(s) < min_length:
|
| 238 |
-
continue
|
| 239 |
-
value = roman_to_int(s)
|
| 240 |
-
if value is None:
|
| 241 |
-
continue
|
| 242 |
-
found.append((match.start(), s, value))
|
| 243 |
-
return found
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 247 |
-
# Classification de la restitution dans l'hypothèse
|
| 248 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 249 |
-
|
| 250 |
-
# Statuts possibles, dans l'ordre de priorité (un numéral est
|
| 251 |
-
# classé selon le premier statut qui s'applique).
|
| 252 |
-
|
| 253 |
-
STATUS_STRICT_PRESERVED = "strict_preserved"
|
| 254 |
-
STATUS_CASE_CHANGED = "case_changed"
|
| 255 |
-
STATUS_J_DROPPED = "j_dropped"
|
| 256 |
-
STATUS_CONVERTED_TO_ARABIC = "converted_to_arabic"
|
| 257 |
-
STATUS_LOST = "lost"
|
| 258 |
-
|
| 259 |
-
ALL_STATUSES = (
|
| 260 |
-
STATUS_STRICT_PRESERVED,
|
| 261 |
-
STATUS_CASE_CHANGED,
|
| 262 |
-
STATUS_J_DROPPED,
|
| 263 |
-
STATUS_CONVERTED_TO_ARABIC,
|
| 264 |
-
STATUS_LOST,
|
| 265 |
-
)
|
| 266 |
-
|
| 267 |
-
# Statuts qui indiquent une préservation de la valeur (par opposition
|
| 268 |
-
# à la perte).
|
| 269 |
-
VALUE_PRESERVING_STATUSES = frozenset({
|
| 270 |
-
STATUS_STRICT_PRESERVED,
|
| 271 |
-
STATUS_CASE_CHANGED,
|
| 272 |
-
STATUS_J_DROPPED,
|
| 273 |
-
STATUS_CONVERTED_TO_ARABIC,
|
| 274 |
-
})
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
def _classify_restitution(numeral: str, value: int, hyp: str) -> str:
|
| 278 |
-
"""Classifie comment ``numeral`` (de valeur ``value``) est
|
| 279 |
-
restitué dans ``hyp`` selon les 5 statuts définis."""
|
| 280 |
-
# 1. Forme stricte présente
|
| 281 |
-
if re.search(r"(?<![A-Za-z])" + re.escape(numeral) + r"(?![A-Za-z])", hyp):
|
| 282 |
-
return STATUS_STRICT_PRESERVED
|
| 283 |
-
# 2. Variante de casse seule
|
| 284 |
-
swapped = numeral.swapcase()
|
| 285 |
-
if swapped != numeral and re.search(
|
| 286 |
-
r"(?<![A-Za-z])" + re.escape(swapped) + r"(?![A-Za-z])", hyp,
|
| 287 |
-
):
|
| 288 |
-
return STATUS_CASE_CHANGED
|
| 289 |
-
# 3. ``j`` final remplacé par ``i`` (ou inverse)
|
| 290 |
-
if numeral.lower().endswith("j"):
|
| 291 |
-
no_j = numeral[:-1] + ("I" if numeral[-1] == "J" else "i")
|
| 292 |
-
elif numeral.lower().endswith("i"):
|
| 293 |
-
no_j = numeral[:-1] + ("J" if numeral[-1] == "I" else "j")
|
| 294 |
-
else:
|
| 295 |
-
no_j = numeral
|
| 296 |
-
if no_j != numeral and re.search(
|
| 297 |
-
r"(?<![A-Za-z])" + re.escape(no_j) + r"(?![A-Za-z])", hyp,
|
| 298 |
-
):
|
| 299 |
-
return STATUS_J_DROPPED
|
| 300 |
-
# Variante de casse + j-flip combinés
|
| 301 |
-
no_j_swapped = no_j.swapcase()
|
| 302 |
-
if no_j_swapped != numeral and re.search(
|
| 303 |
-
r"(?<![A-Za-z])" + re.escape(no_j_swapped) + r"(?![A-Za-z])", hyp,
|
| 304 |
-
):
|
| 305 |
-
return STATUS_J_DROPPED
|
| 306 |
-
# 4. Conversion en chiffres arabes
|
| 307 |
-
if re.search(r"(?<!\d)" + str(value) + r"(?!\d)", hyp):
|
| 308 |
-
return STATUS_CONVERTED_TO_ARABIC
|
| 309 |
-
# 5. Perdu
|
| 310 |
-
return STATUS_LOST
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 314 |
-
# Calcul de la métrique
|
| 315 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
def compute_roman_numeral_metrics(
|
| 319 |
-
reference: Optional[str],
|
| 320 |
-
hypothesis: Optional[str],
|
| 321 |
-
*,
|
| 322 |
-
min_length: int = 1,
|
| 323 |
-
) -> dict:
|
| 324 |
-
"""Calcule la préservation des numéraux romains.
|
| 325 |
-
|
| 326 |
-
Pour chaque numéral romain dans la GT, on classifie sa
|
| 327 |
-
restitution dans l'hypothèse selon l'un des 5 statuts (forme
|
| 328 |
-
stricte / casse modifiée / j supprimé / conversion arabe / perdu).
|
| 329 |
-
|
| 330 |
-
Returns
|
| 331 |
-
-------
|
| 332 |
-
dict
|
| 333 |
-
``{
|
| 334 |
-
"n_numerals_reference": int,
|
| 335 |
-
"n_strict_preserved": int,
|
| 336 |
-
"n_value_preserved": int, # tous statuts sauf LOST
|
| 337 |
-
"global_strict_score": float,
|
| 338 |
-
"global_value_score": float,
|
| 339 |
-
"per_status": {status: count for status in ALL_STATUSES},
|
| 340 |
-
"per_numeral": [
|
| 341 |
-
{"index", "numeral", "value", "status"}
|
| 342 |
-
],
|
| 343 |
-
"lost_numerals": [
|
| 344 |
-
{"index", "numeral", "value"}
|
| 345 |
-
],
|
| 346 |
-
}``
|
| 347 |
-
|
| 348 |
-
Cas dégénérés
|
| 349 |
-
-------------
|
| 350 |
-
- GT vide ou sans numéral → tous compteurs à 0, scores à 0.0,
|
| 351 |
-
``per_status`` initialisé à 0 sur tous les statuts.
|
| 352 |
-
- GT avec numéraux + hyp vide → tous classés ``lost``,
|
| 353 |
-
strict_score = value_score = 0.0.
|
| 354 |
-
"""
|
| 355 |
-
ref = reference or ""
|
| 356 |
-
hyp = hypothesis or ""
|
| 357 |
-
|
| 358 |
-
detected = detect_roman_numerals(ref, min_length=min_length)
|
| 359 |
-
n_total = len(detected)
|
| 360 |
-
per_status_init = {status: 0 for status in ALL_STATUSES}
|
| 361 |
-
|
| 362 |
-
if n_total == 0:
|
| 363 |
-
return {
|
| 364 |
-
"n_numerals_reference": 0,
|
| 365 |
-
"n_strict_preserved": 0,
|
| 366 |
-
"n_value_preserved": 0,
|
| 367 |
-
"global_strict_score": 0.0,
|
| 368 |
-
"global_value_score": 0.0,
|
| 369 |
-
"per_status": per_status_init,
|
| 370 |
-
"per_numeral": [],
|
| 371 |
-
"lost_numerals": [],
|
| 372 |
-
}
|
| 373 |
-
|
| 374 |
-
per_status: dict[str, int] = dict(per_status_init)
|
| 375 |
-
per_numeral: list[dict] = []
|
| 376 |
-
lost: list[dict] = []
|
| 377 |
-
for index, numeral, value in detected:
|
| 378 |
-
status = _classify_restitution(numeral, value, hyp)
|
| 379 |
-
per_status[status] = per_status.get(status, 0) + 1
|
| 380 |
-
per_numeral.append({
|
| 381 |
-
"index": index,
|
| 382 |
-
"numeral": numeral,
|
| 383 |
-
"value": value,
|
| 384 |
-
"status": status,
|
| 385 |
-
})
|
| 386 |
-
if status == STATUS_LOST:
|
| 387 |
-
lost.append({"index": index, "numeral": numeral, "value": value})
|
| 388 |
-
|
| 389 |
-
n_strict = per_status[STATUS_STRICT_PRESERVED]
|
| 390 |
-
n_value = sum(per_status[s] for s in VALUE_PRESERVING_STATUSES)
|
| 391 |
-
|
| 392 |
-
return {
|
| 393 |
-
"n_numerals_reference": n_total,
|
| 394 |
-
"n_strict_preserved": n_strict,
|
| 395 |
-
"n_value_preserved": n_value,
|
| 396 |
-
"global_strict_score": n_strict / n_total,
|
| 397 |
-
"global_value_score": n_value / n_total,
|
| 398 |
-
"per_status": per_status,
|
| 399 |
-
"per_numeral": per_numeral,
|
| 400 |
-
"lost_numerals": lost,
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
def roman_numeral_strict_score(
|
| 405 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 406 |
-
) -> float:
|
| 407 |
-
"""Raccourci : taux global de préservation **stricte** des
|
| 408 |
-
numéraux romains ∈ [0, 1]."""
|
| 409 |
-
return compute_roman_numeral_metrics(
|
| 410 |
-
reference, hypothesis,
|
| 411 |
-
)["global_strict_score"]
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
def roman_numeral_value_score(
|
| 415 |
-
reference: Optional[str], hypothesis: Optional[str],
|
| 416 |
-
) -> float:
|
| 417 |
-
"""Raccourci : taux global de préservation de la **valeur** des
|
| 418 |
-
numéraux romains (toute forme confondue : strict, case_changed,
|
| 419 |
-
j_dropped, arabe) ∈ [0, 1]."""
|
| 420 |
-
return compute_roman_numeral_metrics(
|
| 421 |
-
reference, hypothesis,
|
| 422 |
-
)["global_value_score"]
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 426 |
-
# Enregistrement dans le registre typé (Sprint 34)
|
| 427 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
@register_metric(
|
| 431 |
-
name="roman_numeral_strict_score",
|
| 432 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 433 |
-
description=(
|
| 434 |
-
"Taux de préservation stricte des numéraux romains "
|
| 435 |
-
"(forme exacte gardée : casse, j médiéval final). "
|
| 436 |
-
"Métrique transversale aux périodes médiévale, imprimé "
|
| 437 |
-
"ancien et moderne."
|
| 438 |
-
),
|
| 439 |
-
higher_is_better=True,
|
| 440 |
-
tags={"text", "roman_numerals", "philology"},
|
| 441 |
-
)
|
| 442 |
-
def _registered_strict(reference: str, hypothesis: str) -> float:
|
| 443 |
-
return roman_numeral_strict_score(reference, hypothesis)
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
@register_metric(
|
| 447 |
-
name="roman_numeral_value_score",
|
| 448 |
-
input_types=(ArtifactType.TEXT, ArtifactType.TEXT),
|
| 449 |
-
description=(
|
| 450 |
-
"Taux de préservation de la valeur numérique des numéraux "
|
| 451 |
-
"romains, indépendamment de la forme (strict, casse "
|
| 452 |
-
"changée, j supprimé, conversion en chiffres arabes). "
|
| 453 |
-
"Le breakdown per_status permet au chercheur de juger la "
|
| 454 |
-
"convention adoptée."
|
| 455 |
-
),
|
| 456 |
-
higher_is_better=True,
|
| 457 |
-
tags={"text", "roman_numerals", "philology"},
|
| 458 |
)
|
| 459 |
-
def _registered_value(reference: str, hypothesis: str) -> float:
|
| 460 |
-
return roman_numeral_value_score(reference, hypothesis)
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
__all__ = [
|
| 464 |
-
"ROMAN_VALUES",
|
| 465 |
-
"ALL_STATUSES",
|
| 466 |
-
"STATUS_STRICT_PRESERVED",
|
| 467 |
-
"STATUS_CASE_CHANGED",
|
| 468 |
-
"STATUS_J_DROPPED",
|
| 469 |
-
"STATUS_CONVERTED_TO_ARABIC",
|
| 470 |
-
"STATUS_LOST",
|
| 471 |
-
"VALUE_PRESERVING_STATUSES",
|
| 472 |
-
"compute_roman_numeral_metrics",
|
| 473 |
-
"detect_roman_numerals",
|
| 474 |
-
"int_to_roman",
|
| 475 |
-
"roman_numeral_strict_score",
|
| 476 |
-
"roman_numeral_value_score",
|
| 477 |
-
"roman_to_int",
|
| 478 |
-
]
|
|
|
|
| 1 |
+
"""``picarones.measurements.roman_numerals`` — shim re-export (déprécié, suppression 2.0).
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.evaluation.metrics.roman_numerals`.
|
| 4 |
+
Phase 5.C.batch7 du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.evaluation.metrics.roman_numerals import * # noqa: F401, F403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.measurements.roman_numerals is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.evaluation.metrics.roman_numerals instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -290,7 +290,7 @@ class ReportGenerator:
|
|
| 290 |
from picarones.reports_v2.html.renderers.searchability import (
|
| 291 |
build_searchability_summary_html,
|
| 292 |
)
|
| 293 |
-
from picarones.
|
| 294 |
build_numerical_sequences_html,
|
| 295 |
)
|
| 296 |
# Sprint 87 — A.II.2 : lisibilité (delta Flesch).
|
|
|
|
| 290 |
from picarones.reports_v2.html.renderers.searchability import (
|
| 291 |
build_searchability_summary_html,
|
| 292 |
)
|
| 293 |
+
from picarones.reports_v2.html.renderers.numerical_sequences import (
|
| 294 |
build_numerical_sequences_html,
|
| 295 |
)
|
| 296 |
# Sprint 87 — A.II.2 : lisibilité (delta Flesch).
|
|
@@ -1,149 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
Pattern identique aux autres rendus : server-side, pas de JS,
|
| 7 |
-
anti-injection systématique.
|
| 8 |
-
|
| 9 |
-
Vue
|
| 10 |
-
---
|
| 11 |
-
Tableau moteur × catégorie (year / roman / foliation / currency
|
| 12 |
-
/ regnal) × score strict ; une ligne par moteur, une cellule
|
| 13 |
-
colorée par cellule. Une seconde ligne donne le score ``value``
|
| 14 |
-
(en plus petit). Catégorie omise si **aucun** moteur n'a de
|
| 15 |
-
GT exploitable pour elle.
|
| 16 |
-
|
| 17 |
-
Adaptative : ``""`` si aucun moteur n'a de
|
| 18 |
-
``aggregated_numerical_sequences``.
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
-
|
| 24 |
-
from typing import Optional
|
| 25 |
-
|
| 26 |
-
from picarones.measurements.numerical_sequences import CATEGORIES
|
| 27 |
-
from picarones.reports_v2._helpers.render_helpers import color_traffic_light
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def _category_columns_with_signal(rows: list[dict]) -> list[str]:
|
| 31 |
-
"""Ne garde que les catégories où ≥ 1 moteur a un n_total > 0."""
|
| 32 |
-
visible: list[str] = []
|
| 33 |
-
for cat in CATEGORIES:
|
| 34 |
-
for r in rows:
|
| 35 |
-
agg = r.get("aggregated_numerical_sequences") or {}
|
| 36 |
-
cat_data = (agg.get("per_category") or {}).get(cat) or {}
|
| 37 |
-
if (cat_data.get("n_total") or 0) > 0:
|
| 38 |
-
visible.append(cat)
|
| 39 |
-
break
|
| 40 |
-
return visible
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def build_numerical_sequences_html(
|
| 44 |
-
engines: list[dict],
|
| 45 |
-
labels: Optional[dict[str, str]] = None,
|
| 46 |
-
) -> str:
|
| 47 |
-
"""Construit la section HTML séquences numériques.
|
| 48 |
-
|
| 49 |
-
Returns
|
| 50 |
-
-------
|
| 51 |
-
str
|
| 52 |
-
``""`` si aucun moteur n'a de signal.
|
| 53 |
-
"""
|
| 54 |
-
rows = [
|
| 55 |
-
e for e in engines
|
| 56 |
-
if isinstance(e.get("aggregated_numerical_sequences"), dict)
|
| 57 |
-
]
|
| 58 |
-
if not rows:
|
| 59 |
-
return ""
|
| 60 |
-
visible_cats = _category_columns_with_signal(rows)
|
| 61 |
-
if not visible_cats:
|
| 62 |
-
return ""
|
| 63 |
-
labels = labels or {}
|
| 64 |
-
title = labels.get(
|
| 65 |
-
"numseq_title", "Précision sur séquences numériques",
|
| 66 |
-
)
|
| 67 |
-
note = labels.get(
|
| 68 |
-
"numseq_note",
|
| 69 |
-
"Score strict (forme préservée) — la valeur entre "
|
| 70 |
-
"parenthèses est le score sur la valeur (XIV ↔ 14 "
|
| 71 |
-
"accepté). Foliotation : recto/verso non interchangeables.",
|
| 72 |
-
)
|
| 73 |
-
col_engine = labels.get("numseq_engine", "Moteur")
|
| 74 |
-
col_global = labels.get("numseq_global", "Global")
|
| 75 |
-
cat_label = {
|
| 76 |
-
"year": labels.get("numseq_cat_year", "Année"),
|
| 77 |
-
"roman": labels.get("numseq_cat_roman", "Romain"),
|
| 78 |
-
"foliation": labels.get("numseq_cat_foliation", "Foliation"),
|
| 79 |
-
"currency": labels.get("numseq_cat_currency", "Montant"),
|
| 80 |
-
"regnal": labels.get("numseq_cat_regnal", "Régnal"),
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
parts = [
|
| 84 |
-
'<div class="numseq-section" style="margin:1rem 0">',
|
| 85 |
-
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 86 |
-
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
|
| 87 |
-
f'{_e(note)}</div>',
|
| 88 |
-
'<table style="border-collapse:collapse;width:100%;'
|
| 89 |
-
'font-size:.9rem">',
|
| 90 |
-
'<thead><tr>',
|
| 91 |
-
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
|
| 92 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 93 |
-
f'{_e(col_engine)}</th>',
|
| 94 |
-
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
|
| 95 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 96 |
-
f'{_e(col_global)}</th>',
|
| 97 |
-
]
|
| 98 |
-
for cat in visible_cats:
|
| 99 |
-
parts.append(
|
| 100 |
-
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
|
| 101 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 102 |
-
f'{_e(cat_label.get(cat, cat))}</th>'
|
| 103 |
-
)
|
| 104 |
-
parts.append("</tr></thead><tbody>")
|
| 105 |
-
|
| 106 |
-
for engine in rows:
|
| 107 |
-
agg = engine["aggregated_numerical_sequences"]
|
| 108 |
-
name = engine.get("name") or "?"
|
| 109 |
-
per_cat = agg.get("per_category") or {}
|
| 110 |
-
global_strict = float(agg.get("global_strict_score") or 0.0)
|
| 111 |
-
global_value = float(agg.get("global_value_score") or 0.0)
|
| 112 |
-
n_total = int(agg.get("n_total") or 0)
|
| 113 |
-
global_color = color_traffic_light(global_strict)
|
| 114 |
-
parts.append(
|
| 115 |
-
f'<tr>'
|
| 116 |
-
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
| 117 |
-
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 118 |
-
f'background:{global_color};font-family:monospace;'
|
| 119 |
-
f'font-weight:600">'
|
| 120 |
-
f'{global_strict * 100:.1f}%'
|
| 121 |
-
f'<span style="font-size:.75rem;font-weight:400;'
|
| 122 |
-
f'opacity:.75"> ({global_value * 100:.0f}%, '
|
| 123 |
-
f'n={n_total})</span></td>'
|
| 124 |
-
)
|
| 125 |
-
for cat in visible_cats:
|
| 126 |
-
cat_data = per_cat.get(cat) or {}
|
| 127 |
-
n = int(cat_data.get("n_total") or 0)
|
| 128 |
-
if n == 0:
|
| 129 |
-
parts.append(
|
| 130 |
-
'<td style="padding:.4rem .6rem;text-align:right;'
|
| 131 |
-
'opacity:.4">—</td>'
|
| 132 |
-
)
|
| 133 |
-
continue
|
| 134 |
-
strict = float(cat_data.get("strict_score") or 0.0)
|
| 135 |
-
value = float(cat_data.get("value_score") or 0.0)
|
| 136 |
-
color = color_traffic_light(strict)
|
| 137 |
-
parts.append(
|
| 138 |
-
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 139 |
-
f'background:{color};font-family:monospace">'
|
| 140 |
-
f'{strict * 100:.0f}%'
|
| 141 |
-
f'<span style="font-size:.75rem;opacity:.75"> '
|
| 142 |
-
f'({value * 100:.0f}%, n={n})</span></td>'
|
| 143 |
-
)
|
| 144 |
-
parts.append("</tr>")
|
| 145 |
-
parts.append("</tbody></table></div>")
|
| 146 |
-
return "".join(parts)
|
| 147 |
|
|
|
|
| 148 |
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``picarones.report.numerical_sequences_render`` — shim re-export (déprécié, suppression 2.0).
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.reports_v2.html.renderers.numerical_sequences`.
|
| 4 |
+
Phase 5.C.batch7 du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.reports_v2.html.renderers.numerical_sequences import * # noqa: F401, F403
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.report.numerical_sequences_render is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.reports_v2.html.renderers.numerical_sequences instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
| 18 |
+
)
|
|
@@ -1,707 +1,18 @@
|
|
| 1 |
-
"""
|
| 2 |
-
(Sprint 67).
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
Pattern identique aux Sprints 41 (NER), 43 (calibration) et 62
|
| 8 |
-
(philologie) : rendu **server-side**, pas de JavaScript,
|
| 9 |
-
déterministe, anti-injection systématique via ``html.escape``.
|
| 10 |
-
|
| 11 |
-
Vue distincte du rapport OCR historique
|
| 12 |
-
---------------------------------------
|
| 13 |
-
Le rapport HTML OCR (``picarones/report/generator.py``) attend un
|
| 14 |
-
``BenchmarkResult`` (axe A). Pour les pipelines composées, on
|
| 15 |
-
travaille avec ``PipelineBenchmarkResult`` (axe B, Sprint 64).
|
| 16 |
-
|
| 17 |
-
Ce module fournit donc un rapport **autonome** : la fonction
|
| 18 |
-
``build_pipeline_report_html`` produit un document HTML complet
|
| 19 |
-
(``<!doctype html>...``) que l'utilisateur peut écrire directement
|
| 20 |
-
sur disque, sans dépendre du générateur OCR.
|
| 21 |
-
|
| 22 |
-
Sprint 67 — périmètre
|
| 23 |
-
---------------------
|
| 24 |
-
Inclus :
|
| 25 |
-
|
| 26 |
-
- ``build_pipeline_summary_html(bench)`` — encart résumé global
|
| 27 |
-
(corpus, n_docs, taux de succès, durée totale).
|
| 28 |
-
- ``build_pipeline_steps_table_html(bench)`` — tableau par étape
|
| 29 |
-
(durée mean/median, n_succeeded/failed, error_breakdown,
|
| 30 |
-
métriques aux jonctions).
|
| 31 |
-
- ``build_pipeline_report_html(bench, lang)`` — document HTML
|
| 32 |
-
complet à sauver sur disque.
|
| 33 |
-
|
| 34 |
-
Reporté à Sprint 68 :
|
| 35 |
-
|
| 36 |
-
- Rendu d'un ``PipelineComparisonResult`` (ranking entre N
|
| 37 |
-
pipelines + gain table).
|
| 38 |
-
|
| 39 |
-
Toujours pas de classification automatique
|
| 40 |
-
------------------------------------------
|
| 41 |
-
On affiche les chiffres bruts ; le chercheur lit et conclut.
|
| 42 |
"""
|
| 43 |
|
| 44 |
from __future__ import annotations
|
| 45 |
|
| 46 |
-
|
| 47 |
-
from html import escape as _e
|
| 48 |
-
from typing import Optional
|
| 49 |
-
|
| 50 |
-
from picarones.domain.artifacts import ArtifactType
|
| 51 |
-
from picarones.measurements.pipeline_benchmark import PipelineBenchmarkResult
|
| 52 |
-
from picarones.measurements.pipeline_comparison import PipelineComparisonResult
|
| 53 |
-
from picarones.reports_v2._helpers.render_helpers import color_traffic_light
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 57 |
-
# Helpers communs
|
| 58 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def _format_duration(seconds: float) -> str:
|
| 62 |
-
"""Formate une durée en ms si < 1s, en s sinon."""
|
| 63 |
-
if seconds < 1.0:
|
| 64 |
-
return f"{seconds * 1000:.1f} ms"
|
| 65 |
-
if seconds < 60.0:
|
| 66 |
-
return f"{seconds:.2f} s"
|
| 67 |
-
minutes = int(seconds // 60)
|
| 68 |
-
rest = seconds - minutes * 60
|
| 69 |
-
return f"{minutes}min {rest:.1f}s"
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 73 |
-
# Encart résumé corpus-wide
|
| 74 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
def build_pipeline_summary_html(
|
| 78 |
-
bench: PipelineBenchmarkResult,
|
| 79 |
-
labels: Optional[dict[str, str]] = None,
|
| 80 |
-
) -> str:
|
| 81 |
-
"""Construit l'encart résumé global du benchmark."""
|
| 82 |
-
labels = labels or {}
|
| 83 |
-
title = labels.get("pipeline_summary_title", "Résumé du benchmark")
|
| 84 |
-
pipeline_label = labels.get("pipeline_name_label", "Pipeline")
|
| 85 |
-
corpus_label = labels.get("pipeline_corpus_label", "Corpus")
|
| 86 |
-
n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
|
| 87 |
-
succeeded_label = labels.get(
|
| 88 |
-
"pipeline_succeeded_label", "Pipelines réussies",
|
| 89 |
-
)
|
| 90 |
-
failed_label = labels.get("pipeline_failed_label", "Pipelines échouées")
|
| 91 |
-
duration_label = labels.get("pipeline_duration_label", "Durée totale")
|
| 92 |
-
|
| 93 |
-
success = bench.n_pipelines_succeeded
|
| 94 |
-
failed = bench.n_pipelines_failed
|
| 95 |
-
total = bench.n_docs
|
| 96 |
-
rate = success / total if total > 0 else 0.0
|
| 97 |
-
color = color_traffic_light(rate)
|
| 98 |
-
|
| 99 |
-
parts = [
|
| 100 |
-
'<div class="pipeline-summary" '
|
| 101 |
-
'style="margin:1rem 0;padding:.75rem;'
|
| 102 |
-
'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
|
| 103 |
-
f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
|
| 104 |
-
'<table style="border-collapse:collapse;font-size:.9rem">',
|
| 105 |
-
]
|
| 106 |
-
rows = [
|
| 107 |
-
(pipeline_label, _e(bench.pipeline_name)),
|
| 108 |
-
(corpus_label, _e(bench.corpus_name)),
|
| 109 |
-
(n_docs_label, str(total)),
|
| 110 |
-
(
|
| 111 |
-
succeeded_label,
|
| 112 |
-
f'<span style="background:{color};padding:.1rem .4rem;'
|
| 113 |
-
f'border-radius:3px">{success} / {total}</span>',
|
| 114 |
-
),
|
| 115 |
-
(failed_label, str(failed)),
|
| 116 |
-
(duration_label, _e(_format_duration(bench.total_duration_seconds))),
|
| 117 |
-
]
|
| 118 |
-
for label, value in rows:
|
| 119 |
-
parts.append(
|
| 120 |
-
f'<tr>'
|
| 121 |
-
f'<td style="padding:.2rem .5rem;font-weight:500;'
|
| 122 |
-
f'color:#555">{_e(label)}</td>'
|
| 123 |
-
f'<td style="padding:.2rem .5rem">{value}</td>'
|
| 124 |
-
f'</tr>'
|
| 125 |
-
)
|
| 126 |
-
parts.append("</table></div>")
|
| 127 |
-
return "".join(parts)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 131 |
-
# Tableau par étape
|
| 132 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def build_pipeline_steps_table_html(
|
| 136 |
-
bench: PipelineBenchmarkResult,
|
| 137 |
-
labels: Optional[dict[str, str]] = None,
|
| 138 |
-
) -> str:
|
| 139 |
-
"""Construit le tableau par étape de la pipeline.
|
| 140 |
-
|
| 141 |
-
Colonnes : nom de l'étape, n_succeeded, n_failed, taux de
|
| 142 |
-
succès (cellule colorée), durée mean/median, métriques aux
|
| 143 |
-
jonctions (mean) regroupées par type, error_breakdown
|
| 144 |
-
catégorisé.
|
| 145 |
-
"""
|
| 146 |
-
if not bench.per_step_aggregates:
|
| 147 |
-
return ""
|
| 148 |
-
labels = labels or {}
|
| 149 |
-
title = labels.get("pipeline_steps_title", "Détail par étape")
|
| 150 |
-
name_label = labels.get("pipeline_step_name_label", "Étape")
|
| 151 |
-
succ_label = labels.get("pipeline_succeeded_label", "Réussies")
|
| 152 |
-
fail_label = labels.get("pipeline_failed_label", "Échouées")
|
| 153 |
-
rate_label = labels.get("pipeline_success_rate_label", "Taux succès")
|
| 154 |
-
dmean_label = labels.get("pipeline_duration_mean_label", "Durée moyenne")
|
| 155 |
-
dmedian_label = labels.get(
|
| 156 |
-
"pipeline_duration_median_label", "Durée médiane",
|
| 157 |
-
)
|
| 158 |
-
metrics_label = labels.get(
|
| 159 |
-
"pipeline_junction_metrics_label", "Métriques aux jonctions",
|
| 160 |
-
)
|
| 161 |
-
errors_label = labels.get("pipeline_error_breakdown_label", "Erreurs")
|
| 162 |
-
|
| 163 |
-
parts = [
|
| 164 |
-
'<div class="pipeline-steps" style="margin:1rem 0">',
|
| 165 |
-
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 166 |
-
'<table style="border-collapse:collapse;font-size:.85rem;'
|
| 167 |
-
'width:100%">',
|
| 168 |
-
'<thead><tr>',
|
| 169 |
-
]
|
| 170 |
-
for col in (
|
| 171 |
-
name_label, succ_label, fail_label, rate_label,
|
| 172 |
-
dmean_label, dmedian_label, metrics_label, errors_label,
|
| 173 |
-
):
|
| 174 |
-
parts.append(
|
| 175 |
-
f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
|
| 176 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 177 |
-
f'{_e(col)}</th>'
|
| 178 |
-
)
|
| 179 |
-
parts.append("</tr></thead><tbody>")
|
| 180 |
-
|
| 181 |
-
for agg in bench.per_step_aggregates:
|
| 182 |
-
rate = agg.success_rate
|
| 183 |
-
rate_color = color_traffic_light(rate)
|
| 184 |
-
# Métriques aux jonctions : pour chaque type d'artefact,
|
| 185 |
-
# liste des métriques mean
|
| 186 |
-
metrics_cells: list[str] = []
|
| 187 |
-
for at_value, type_metrics in sorted(agg.junction_metrics.items()):
|
| 188 |
-
type_str = _e(at_value)
|
| 189 |
-
for mname, stats in sorted(type_metrics.items()):
|
| 190 |
-
mean = stats["mean"]
|
| 191 |
-
n = stats["n"]
|
| 192 |
-
metrics_cells.append(
|
| 193 |
-
f'<div style="font-size:.8rem;line-height:1.3">'
|
| 194 |
-
f'<code>{type_str}.{_e(mname)}</code>: '
|
| 195 |
-
f'{mean:.3f} '
|
| 196 |
-
f'<span style="opacity:.6">(n={n})</span></div>'
|
| 197 |
-
)
|
| 198 |
-
metrics_html = "".join(metrics_cells) or (
|
| 199 |
-
'<span style="opacity:.5">—</span>'
|
| 200 |
-
)
|
| 201 |
-
# Error breakdown
|
| 202 |
-
err_cells: list[str] = []
|
| 203 |
-
for label, count in sorted(agg.error_breakdown.items()):
|
| 204 |
-
err_cells.append(
|
| 205 |
-
f'<div style="font-size:.8rem;line-height:1.3">'
|
| 206 |
-
f'<code>{_e(label)}</code>: {count}</div>'
|
| 207 |
-
)
|
| 208 |
-
err_html = "".join(err_cells) or (
|
| 209 |
-
'<span style="opacity:.5">—</span>'
|
| 210 |
-
)
|
| 211 |
-
|
| 212 |
-
parts.append(
|
| 213 |
-
f'<tr>'
|
| 214 |
-
f'<td style="padding:.3rem .5rem;font-weight:500">'
|
| 215 |
-
f'{_e(agg.step_name)}</td>'
|
| 216 |
-
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 217 |
-
f'{agg.n_succeeded}</td>'
|
| 218 |
-
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 219 |
-
f'{agg.n_failed}</td>'
|
| 220 |
-
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 221 |
-
f'background:{rate_color}">{rate * 100:.0f}%</td>'
|
| 222 |
-
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 223 |
-
f'{_e(_format_duration(agg.duration_seconds_mean))}</td>'
|
| 224 |
-
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 225 |
-
f'{_e(_format_duration(agg.duration_seconds_median))}</td>'
|
| 226 |
-
f'<td style="padding:.3rem .5rem">{metrics_html}</td>'
|
| 227 |
-
f'<td style="padding:.3rem .5rem">{err_html}</td>'
|
| 228 |
-
f'</tr>'
|
| 229 |
-
)
|
| 230 |
-
parts.append("</tbody></table></div>")
|
| 231 |
-
return "".join(parts)
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 235 |
-
# Document HTML autonome
|
| 236 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
_DOC_STYLES = """
|
| 240 |
-
:root {
|
| 241 |
-
--bg-primary: #ffffff;
|
| 242 |
-
--bg-secondary: #f7f7f7;
|
| 243 |
-
--text-primary: #222;
|
| 244 |
-
--text-muted: #666;
|
| 245 |
-
--border: #ddd;
|
| 246 |
-
}
|
| 247 |
-
* { box-sizing: border-box; }
|
| 248 |
-
body {
|
| 249 |
-
margin: 0;
|
| 250 |
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 251 |
-
background: var(--bg-primary);
|
| 252 |
-
color: var(--text-primary);
|
| 253 |
-
line-height: 1.5;
|
| 254 |
-
}
|
| 255 |
-
header {
|
| 256 |
-
padding: 1.5rem 2rem;
|
| 257 |
-
border-bottom: 1px solid var(--border);
|
| 258 |
-
}
|
| 259 |
-
header h1 { margin: 0 0 .3rem 0; font-size: 1.4rem; }
|
| 260 |
-
header .subtitle { color: var(--text-muted); font-size: .9rem; }
|
| 261 |
-
main { padding: 1rem 2rem 3rem 2rem; max-width: 1400px; margin: 0 auto; }
|
| 262 |
-
table { border: 1px solid var(--border); }
|
| 263 |
-
code { background: #f0f0f0; padding: 0 .2rem; border-radius: 2px; font-size: .85em; }
|
| 264 |
-
.note {
|
| 265 |
-
font-size: .85rem;
|
| 266 |
-
color: var(--text-muted);
|
| 267 |
-
font-style: italic;
|
| 268 |
-
margin: .5rem 0 1.5rem 0;
|
| 269 |
-
}
|
| 270 |
-
"""
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
def build_pipeline_report_html(
|
| 274 |
-
bench: PipelineBenchmarkResult,
|
| 275 |
-
labels: Optional[dict[str, str]] = None,
|
| 276 |
-
lang: str = "fr",
|
| 277 |
-
) -> str:
|
| 278 |
-
"""Construit un document HTML autonome pour un benchmark de
|
| 279 |
-
pipeline composée.
|
| 280 |
-
|
| 281 |
-
Le document est complet (``<!doctype html>...``) et peut être
|
| 282 |
-
sauvé directement sur disque par l'utilisateur :
|
| 283 |
-
|
| 284 |
-
>>> html = build_pipeline_report_html(bench)
|
| 285 |
-
>>> Path("rapport_pipeline.html").write_text(html)
|
| 286 |
-
"""
|
| 287 |
-
labels = labels or {}
|
| 288 |
-
main_title = labels.get(
|
| 289 |
-
"pipeline_report_title", "Rapport de pipeline composée",
|
| 290 |
-
)
|
| 291 |
-
note = labels.get(
|
| 292 |
-
"pipeline_report_note",
|
| 293 |
-
"Données brutes par étape. L'outil mesure et agrège — il "
|
| 294 |
-
"ne classe pas la pipeline « bonne » ou « mauvaise ». "
|
| 295 |
-
"C'est au chercheur de juger les chiffres selon ses critères.",
|
| 296 |
-
)
|
| 297 |
-
summary = build_pipeline_summary_html(bench, labels)
|
| 298 |
-
steps = build_pipeline_steps_table_html(bench, labels)
|
| 299 |
-
|
| 300 |
-
title_text = f"{main_title} — {bench.pipeline_name}"
|
| 301 |
-
parts = [
|
| 302 |
-
"<!doctype html>",
|
| 303 |
-
f'<html lang="{_e(lang)}">',
|
| 304 |
-
"<head>",
|
| 305 |
-
'<meta charset="utf-8">',
|
| 306 |
-
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
| 307 |
-
f"<title>{_e(title_text)}</title>",
|
| 308 |
-
"<style>", _DOC_STYLES, "</style>",
|
| 309 |
-
"</head>",
|
| 310 |
-
"<body>",
|
| 311 |
-
"<header>",
|
| 312 |
-
f"<h1>{_e(title_text)}</h1>",
|
| 313 |
-
f'<div class="subtitle">{_e(bench.corpus_name)} — '
|
| 314 |
-
f'{bench.n_docs} {_e(labels.get("pipeline_docs_short", "docs"))}'
|
| 315 |
-
f'</div>',
|
| 316 |
-
"</header>",
|
| 317 |
-
"<main>",
|
| 318 |
-
f'<p class="note">{_e(note)}</p>',
|
| 319 |
-
summary,
|
| 320 |
-
steps,
|
| 321 |
-
"</main>",
|
| 322 |
-
"</body>",
|
| 323 |
-
"</html>",
|
| 324 |
-
]
|
| 325 |
-
return "".join(parts)
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 329 |
-
# Sprint 68 — comparaison de N pipelines : ranking + gain table
|
| 330 |
-
# ──────────────────────────────────────────────────────────────────────────
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
@dataclass
|
| 334 |
-
class RankingSpec:
|
| 335 |
-
"""Spec d'un classement à afficher.
|
| 336 |
-
|
| 337 |
-
Décrit la jonction (``artifact_type``) et la métrique
|
| 338 |
-
(``metric_name``) à utiliser pour classer les pipelines.
|
| 339 |
-
|
| 340 |
-
Attributs
|
| 341 |
-
---------
|
| 342 |
-
artifact_type:
|
| 343 |
-
Type d'artefact où la métrique est calculée (typiquement
|
| 344 |
-
``ArtifactType.TEXT`` pour des métriques OCR).
|
| 345 |
-
metric_name:
|
| 346 |
-
Nom de la métrique dans le registre typé Sprint 34
|
| 347 |
-
(``"cer"``, ``"wer"``, ``"flesch_delta_fr"``, etc.).
|
| 348 |
-
higher_is_better:
|
| 349 |
-
``False`` (défaut) pour les métriques d'erreur (CER, WER) ;
|
| 350 |
-
``True`` pour les métriques de qualité (accuracy, F1,
|
| 351 |
-
coverage…).
|
| 352 |
-
label:
|
| 353 |
-
Libellé optionnel à afficher dans le tableau ; sinon
|
| 354 |
-
construit comme ``"<artifact_type>.<metric_name>"``.
|
| 355 |
-
"""
|
| 356 |
-
|
| 357 |
-
artifact_type: ArtifactType
|
| 358 |
-
metric_name: str
|
| 359 |
-
higher_is_better: bool = False
|
| 360 |
-
label: Optional[str] = None
|
| 361 |
-
|
| 362 |
-
@property
|
| 363 |
-
def display_label(self) -> str:
|
| 364 |
-
if self.label:
|
| 365 |
-
return self.label
|
| 366 |
-
return f"{self.artifact_type.value}.{self.metric_name}"
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
def _bg_for_rank(rank: int, total: int) -> str:
|
| 370 |
-
"""Gradient vert (rang 1) → rouge (dernier rang).
|
| 371 |
-
|
| 372 |
-
Mapping : ``rank ∈ [1, total]`` → ``color_traffic_light`` avec
|
| 373 |
-
``low_is_good=True`` (rang bas = bon).
|
| 374 |
-
"""
|
| 375 |
-
if total <= 1:
|
| 376 |
-
return color_traffic_light(1.0)
|
| 377 |
-
return color_traffic_light(
|
| 378 |
-
float(rank), low_is_good=True, scale_min=1.0, scale_max=float(total),
|
| 379 |
-
)
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
def build_pipeline_ranking_table_html(
|
| 383 |
-
comparison: PipelineComparisonResult,
|
| 384 |
-
ranking_spec: RankingSpec,
|
| 385 |
-
labels: Optional[dict[str, str]] = None,
|
| 386 |
-
) -> str:
|
| 387 |
-
"""Tableau de classement des pipelines selon une métrique finale.
|
| 388 |
-
|
| 389 |
-
Colonnes : rang, nom du pipeline, valeur de la métrique (mean
|
| 390 |
-
sur le corpus à la dernière jonction qui produit
|
| 391 |
-
``artifact_type``). Les pipelines sans valeur sont listés en
|
| 392 |
-
queue avec un tiret.
|
| 393 |
-
"""
|
| 394 |
-
labels = labels or {}
|
| 395 |
-
title_template = labels.get(
|
| 396 |
-
"pipeline_ranking_title", "Classement par {label}",
|
| 397 |
-
)
|
| 398 |
-
title = title_template.format(label=ranking_spec.display_label)
|
| 399 |
-
rank_label = labels.get("pipeline_rank_label", "Rang")
|
| 400 |
-
name_label = labels.get("pipeline_name_label", "Pipeline")
|
| 401 |
-
value_label = labels.get("pipeline_value_label", "Valeur")
|
| 402 |
-
|
| 403 |
-
ranked = comparison.ranking_by_final_metric(
|
| 404 |
-
ranking_spec.artifact_type,
|
| 405 |
-
ranking_spec.metric_name,
|
| 406 |
-
higher_is_better=ranking_spec.higher_is_better,
|
| 407 |
-
)
|
| 408 |
-
if not ranked:
|
| 409 |
-
return ""
|
| 410 |
-
|
| 411 |
-
n_with_value = sum(1 for _name, v in ranked if v is not None)
|
| 412 |
-
|
| 413 |
-
parts = [
|
| 414 |
-
'<div class="pipeline-ranking" style="margin:1rem 0">',
|
| 415 |
-
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 416 |
-
'<table style="border-collapse:collapse;font-size:.85rem">',
|
| 417 |
-
'<thead><tr>',
|
| 418 |
-
]
|
| 419 |
-
for col in (rank_label, name_label, value_label):
|
| 420 |
-
parts.append(
|
| 421 |
-
f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
|
| 422 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 423 |
-
f'{_e(col)}</th>'
|
| 424 |
-
)
|
| 425 |
-
parts.append("</tr></thead><tbody>")
|
| 426 |
-
|
| 427 |
-
rank = 0
|
| 428 |
-
for name, value in ranked:
|
| 429 |
-
if value is None:
|
| 430 |
-
rank_str = "—"
|
| 431 |
-
value_str = "—"
|
| 432 |
-
rank_color = "#f0f0f0"
|
| 433 |
-
else:
|
| 434 |
-
rank += 1
|
| 435 |
-
rank_str = str(rank)
|
| 436 |
-
value_str = f"{value:.4f}"
|
| 437 |
-
rank_color = _bg_for_rank(rank, n_with_value)
|
| 438 |
-
parts.append(
|
| 439 |
-
f'<tr>'
|
| 440 |
-
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 441 |
-
f'background:{rank_color};font-weight:600">{rank_str}</td>'
|
| 442 |
-
f'<td style="padding:.3rem .5rem">{_e(name)}</td>'
|
| 443 |
-
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 444 |
-
f'font-family:monospace">{value_str}</td>'
|
| 445 |
-
f'</tr>'
|
| 446 |
-
)
|
| 447 |
-
parts.append("</tbody></table></div>")
|
| 448 |
-
return "".join(parts)
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
def build_pipeline_gain_table_html(
|
| 452 |
-
comparison: PipelineComparisonResult,
|
| 453 |
-
ranking_spec: RankingSpec,
|
| 454 |
-
baseline_pipeline: str,
|
| 455 |
-
labels: Optional[dict[str, str]] = None,
|
| 456 |
-
) -> str:
|
| 457 |
-
"""Tableau gain vs baseline pour une métrique donnée.
|
| 458 |
-
|
| 459 |
-
Colonnes : pipeline, valeur, gain absolu, gain relatif. La
|
| 460 |
-
baseline est marquée explicitement (cellule grisée).
|
| 461 |
-
Convention de couleur : vert si gain favorable selon
|
| 462 |
-
``higher_is_better``, rouge sinon.
|
| 463 |
-
"""
|
| 464 |
-
labels = labels or {}
|
| 465 |
-
title_template = labels.get(
|
| 466 |
-
"pipeline_gain_title", "Gain vs {baseline} sur {label}",
|
| 467 |
-
)
|
| 468 |
-
title = title_template.format(
|
| 469 |
-
baseline=baseline_pipeline,
|
| 470 |
-
label=ranking_spec.display_label,
|
| 471 |
-
)
|
| 472 |
-
name_label = labels.get("pipeline_name_label", "Pipeline")
|
| 473 |
-
value_label = labels.get("pipeline_value_label", "Valeur")
|
| 474 |
-
abs_label = labels.get("pipeline_gain_absolute_label", "Gain absolu")
|
| 475 |
-
rel_label = labels.get("pipeline_gain_relative_label", "Gain relatif")
|
| 476 |
-
baseline_label = labels.get(
|
| 477 |
-
"pipeline_baseline_marker", "(référence)",
|
| 478 |
-
)
|
| 479 |
-
|
| 480 |
-
try:
|
| 481 |
-
gains = comparison.gain_table(
|
| 482 |
-
ranking_spec.artifact_type,
|
| 483 |
-
ranking_spec.metric_name,
|
| 484 |
-
baseline_pipeline,
|
| 485 |
-
)
|
| 486 |
-
except KeyError:
|
| 487 |
-
return ""
|
| 488 |
-
|
| 489 |
-
parts = [
|
| 490 |
-
'<div class="pipeline-gain" style="margin:1rem 0">',
|
| 491 |
-
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 492 |
-
'<table style="border-collapse:collapse;font-size:.85rem">',
|
| 493 |
-
'<thead><tr>',
|
| 494 |
-
]
|
| 495 |
-
for col in (name_label, value_label, abs_label, rel_label):
|
| 496 |
-
parts.append(
|
| 497 |
-
f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
|
| 498 |
-
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 499 |
-
f'{_e(col)}</th>'
|
| 500 |
-
)
|
| 501 |
-
parts.append("</tr></thead><tbody>")
|
| 502 |
-
|
| 503 |
-
for name, g in gains.items():
|
| 504 |
-
is_baseline = name == baseline_pipeline
|
| 505 |
-
value = g["value"]
|
| 506 |
-
absolute = g["absolute"]
|
| 507 |
-
relative = g["relative"]
|
| 508 |
-
# Formatage des cellules
|
| 509 |
-
value_str = "—" if value is None else f"{value:.4f}"
|
| 510 |
-
abs_str = "—" if absolute is None else f"{absolute:+.4f}"
|
| 511 |
-
rel_str = "—" if relative is None else f"{relative * 100:+.1f}%"
|
| 512 |
-
# Couleur du gain : vert si favorable, rouge sinon, gris pour
|
| 513 |
-
# la baseline.
|
| 514 |
-
if is_baseline:
|
| 515 |
-
gain_color = "#f0f0f0"
|
| 516 |
-
elif absolute is None or absolute == 0:
|
| 517 |
-
gain_color = "#f0f0f0"
|
| 518 |
-
else:
|
| 519 |
-
favorable = (
|
| 520 |
-
absolute > 0 if ranking_spec.higher_is_better else absolute < 0
|
| 521 |
-
)
|
| 522 |
-
gain_color = "#cfe8cf" if favorable else "#f4cfcf"
|
| 523 |
-
# Marqueur baseline
|
| 524 |
-
name_cell = _e(name)
|
| 525 |
-
if is_baseline:
|
| 526 |
-
name_cell += (
|
| 527 |
-
f' <span style="opacity:.6;font-size:.85em">'
|
| 528 |
-
f'{_e(baseline_label)}</span>'
|
| 529 |
-
)
|
| 530 |
-
parts.append(
|
| 531 |
-
f'<tr>'
|
| 532 |
-
f'<td style="padding:.3rem .5rem;font-weight:500">{name_cell}</td>'
|
| 533 |
-
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 534 |
-
f'font-family:monospace">{value_str}</td>'
|
| 535 |
-
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 536 |
-
f'font-family:monospace;background:{gain_color}">{abs_str}</td>'
|
| 537 |
-
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 538 |
-
f'font-family:monospace;background:{gain_color}">{rel_str}</td>'
|
| 539 |
-
f'</tr>'
|
| 540 |
-
)
|
| 541 |
-
parts.append("</tbody></table></div>")
|
| 542 |
-
return "".join(parts)
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
def build_pipeline_comparison_summary_html(
|
| 546 |
-
comparison: PipelineComparisonResult,
|
| 547 |
-
labels: Optional[dict[str, str]] = None,
|
| 548 |
-
) -> str:
|
| 549 |
-
"""Encart de résumé global d'une comparaison de pipelines.
|
| 550 |
-
|
| 551 |
-
Affiche corpus, n_docs, durée totale, nombre de pipelines, et
|
| 552 |
-
pour chacune un mini-résumé n_succeeded / n_docs.
|
| 553 |
-
"""
|
| 554 |
-
labels = labels or {}
|
| 555 |
-
title = labels.get(
|
| 556 |
-
"pipeline_comparison_summary_title", "Résumé de la comparaison",
|
| 557 |
-
)
|
| 558 |
-
corpus_label = labels.get("pipeline_corpus_label", "Corpus")
|
| 559 |
-
n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
|
| 560 |
-
n_pipelines_label = labels.get(
|
| 561 |
-
"pipeline_n_pipelines_label", "Pipelines comparées",
|
| 562 |
-
)
|
| 563 |
-
duration_label = labels.get("pipeline_duration_label", "Durée totale")
|
| 564 |
-
|
| 565 |
-
parts = [
|
| 566 |
-
'<div class="pipeline-comparison-summary" '
|
| 567 |
-
'style="margin:1rem 0;padding:.75rem;'
|
| 568 |
-
'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
|
| 569 |
-
f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
|
| 570 |
-
'<table style="border-collapse:collapse;font-size:.9rem">',
|
| 571 |
-
]
|
| 572 |
-
rows = [
|
| 573 |
-
(corpus_label, _e(comparison.corpus_name)),
|
| 574 |
-
(n_docs_label, str(comparison.n_docs)),
|
| 575 |
-
(n_pipelines_label, str(len(comparison.per_pipeline))),
|
| 576 |
-
(duration_label, _e(_format_duration(comparison.total_duration_seconds))),
|
| 577 |
-
]
|
| 578 |
-
for label, value in rows:
|
| 579 |
-
parts.append(
|
| 580 |
-
f'<tr>'
|
| 581 |
-
f'<td style="padding:.2rem .5rem;font-weight:500;color:#555">'
|
| 582 |
-
f'{_e(label)}</td>'
|
| 583 |
-
f'<td style="padding:.2rem .5rem">{value}</td>'
|
| 584 |
-
f'</tr>'
|
| 585 |
-
)
|
| 586 |
-
parts.append("</table>")
|
| 587 |
-
# Mini-résumé par pipeline
|
| 588 |
-
if comparison.per_pipeline:
|
| 589 |
-
per_pipeline_label = labels.get(
|
| 590 |
-
"pipeline_per_pipeline_label", "Par pipeline",
|
| 591 |
-
)
|
| 592 |
-
parts.append(
|
| 593 |
-
f'<div style="margin-top:.6rem;font-size:.85rem">'
|
| 594 |
-
f'<span style="font-weight:500;color:#555">'
|
| 595 |
-
f'{_e(per_pipeline_label)} :</span>'
|
| 596 |
-
)
|
| 597 |
-
items: list[str] = []
|
| 598 |
-
for name, bench in comparison.per_pipeline.items():
|
| 599 |
-
items.append(
|
| 600 |
-
f'<code>{_e(name)}</code> '
|
| 601 |
-
f'({bench.n_pipelines_succeeded}/{bench.n_docs})'
|
| 602 |
-
)
|
| 603 |
-
parts.append(" — ".join(items))
|
| 604 |
-
parts.append("</div>")
|
| 605 |
-
parts.append("</div>")
|
| 606 |
-
return "".join(parts)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
def build_pipeline_comparison_report_html(
|
| 610 |
-
comparison: PipelineComparisonResult,
|
| 611 |
-
ranking_specs: Optional[list[RankingSpec]] = None,
|
| 612 |
-
baseline_pipeline: Optional[str] = None,
|
| 613 |
-
labels: Optional[dict[str, str]] = None,
|
| 614 |
-
lang: str = "fr",
|
| 615 |
-
) -> str:
|
| 616 |
-
"""Document HTML autonome pour une comparaison de N pipelines.
|
| 617 |
-
|
| 618 |
-
Parameters
|
| 619 |
-
----------
|
| 620 |
-
comparison:
|
| 621 |
-
Résultat de ``compare_pipelines`` (Sprint 65).
|
| 622 |
-
ranking_specs:
|
| 623 |
-
Liste explicite des classements à afficher. Pour chaque
|
| 624 |
-
spec, on rend un tableau de classement et, si
|
| 625 |
-
``baseline_pipeline`` est fourni, un tableau de gain.
|
| 626 |
-
Si ``None`` ou vide, on affiche uniquement le résumé
|
| 627 |
-
global et les résumés par pipeline (sans verdict).
|
| 628 |
-
baseline_pipeline:
|
| 629 |
-
Pipeline de référence pour les tableaux de gain. Si
|
| 630 |
-
``None``, les tableaux de gain ne sont pas affichés.
|
| 631 |
-
labels:
|
| 632 |
-
Map i18n.
|
| 633 |
-
lang:
|
| 634 |
-
Code langue pour ``<html lang="…">``.
|
| 635 |
-
|
| 636 |
-
Returns
|
| 637 |
-
-------
|
| 638 |
-
str
|
| 639 |
-
Document HTML complet (``<!doctype html>`` + ``<html>``).
|
| 640 |
-
"""
|
| 641 |
-
labels = labels or {}
|
| 642 |
-
main_title = labels.get(
|
| 643 |
-
"pipeline_comparison_report_title",
|
| 644 |
-
"Rapport de comparaison de pipelines",
|
| 645 |
-
)
|
| 646 |
-
note = labels.get(
|
| 647 |
-
"pipeline_comparison_report_note",
|
| 648 |
-
"Données comparatives brutes. L'outil mesure et classe — il "
|
| 649 |
-
"ne tranche pas le débat éditorial. C'est au chercheur de "
|
| 650 |
-
"lire les chiffres et de conclure selon ses critères.",
|
| 651 |
-
)
|
| 652 |
-
title_text = f"{main_title} — {comparison.corpus_name}"
|
| 653 |
-
summary = build_pipeline_comparison_summary_html(comparison, labels)
|
| 654 |
-
|
| 655 |
-
rankings_html: list[str] = []
|
| 656 |
-
for spec in (ranking_specs or []):
|
| 657 |
-
rankings_html.append(
|
| 658 |
-
build_pipeline_ranking_table_html(comparison, spec, labels),
|
| 659 |
-
)
|
| 660 |
-
if baseline_pipeline is not None:
|
| 661 |
-
rankings_html.append(
|
| 662 |
-
build_pipeline_gain_table_html(
|
| 663 |
-
comparison, spec, baseline_pipeline, labels,
|
| 664 |
-
),
|
| 665 |
-
)
|
| 666 |
-
|
| 667 |
-
parts = [
|
| 668 |
-
"<!doctype html>",
|
| 669 |
-
f'<html lang="{_e(lang)}">',
|
| 670 |
-
"<head>",
|
| 671 |
-
'<meta charset="utf-8">',
|
| 672 |
-
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
| 673 |
-
f"<title>{_e(title_text)}</title>",
|
| 674 |
-
"<style>", _DOC_STYLES, "</style>",
|
| 675 |
-
"</head>",
|
| 676 |
-
"<body>",
|
| 677 |
-
"<header>",
|
| 678 |
-
f"<h1>{_e(title_text)}</h1>",
|
| 679 |
-
f'<div class="subtitle">{len(comparison.per_pipeline)} '
|
| 680 |
-
f'{_e(labels.get("pipeline_n_pipelines_short", "pipelines"))} '
|
| 681 |
-
f'— {comparison.n_docs} '
|
| 682 |
-
f'{_e(labels.get("pipeline_docs_short", "docs"))}'
|
| 683 |
-
f'</div>',
|
| 684 |
-
"</header>",
|
| 685 |
-
"<main>",
|
| 686 |
-
f'<p class="note">{_e(note)}</p>',
|
| 687 |
-
summary,
|
| 688 |
-
]
|
| 689 |
-
parts.extend(rankings_html)
|
| 690 |
-
parts.extend([
|
| 691 |
-
"</main>",
|
| 692 |
-
"</body>",
|
| 693 |
-
"</html>",
|
| 694 |
-
])
|
| 695 |
-
return "".join(parts)
|
| 696 |
|
|
|
|
| 697 |
|
| 698 |
-
|
| 699 |
-
"
|
| 700 |
-
"
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
"build_pipeline_gain_table_html",
|
| 705 |
-
"build_pipeline_comparison_summary_html",
|
| 706 |
-
"build_pipeline_comparison_report_html",
|
| 707 |
-
]
|
|
|
|
| 1 |
+
"""``picarones.report.pipeline_render`` — shim re-export (déprécié, suppression 2.0).
|
|
|
|
| 2 |
|
| 3 |
+
Canonique : :mod:`picarones.reports_v2.html.renderers.pipeline`.
|
| 4 |
+
Phase 5.C.batch7 du retrait du legacy.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
import warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
from picarones.reports_v2.html.renderers.pipeline import * # noqa: F401, F403
|
| 12 |
|
| 13 |
+
warnings.warn(
|
| 14 |
+
"picarones.report.pipeline_render is deprecated and will be removed in 2.0. "
|
| 15 |
+
"Import from picarones.reports_v2.html.renderers.pipeline instead.",
|
| 16 |
+
DeprecationWarning,
|
| 17 |
+
stacklevel=2,
|
| 18 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -101,7 +101,7 @@ def build_pipeline_view_html(
|
|
| 101 |
# Sous-section 1 : résumé + steps table
|
| 102 |
if pipeline_benchmark is not None:
|
| 103 |
try:
|
| 104 |
-
from picarones.
|
| 105 |
build_pipeline_steps_table_html,
|
| 106 |
build_pipeline_summary_html,
|
| 107 |
)
|
|
|
|
| 101 |
# Sous-section 1 : résumé + steps table
|
| 102 |
if pipeline_benchmark is not None:
|
| 103 |
try:
|
| 104 |
+
from picarones.reports_v2.html.renderers.pipeline import (
|
| 105 |
build_pipeline_steps_table_html,
|
| 106 |
build_pipeline_summary_html,
|
| 107 |
)
|
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML « Précision sur séquences numériques » — Sprint 86.
|
| 2 |
+
|
| 3 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 4 |
+
``picarones.report.numerical_sequences_render`` vers
|
| 5 |
+
``picarones.reports_v2.html.renderers.numerical_sequences``.
|
| 6 |
+
Le chemin legacy reste disponible via un shim avec
|
| 7 |
+
``DeprecationWarning`` ; suppression prévue en 2.0.
|
| 8 |
+
|
| 9 |
+
Suite directe ``picarones/core/numerical_sequences.py``
|
| 10 |
+
(Sprint 85) + câblage runner Sprint 86.
|
| 11 |
+
|
| 12 |
+
Pattern identique aux autres rendus : server-side, pas de JS,
|
| 13 |
+
anti-injection systématique.
|
| 14 |
+
|
| 15 |
+
Vue
|
| 16 |
+
---
|
| 17 |
+
Tableau moteur × catégorie (year / roman / foliation / currency
|
| 18 |
+
/ regnal) × score strict ; une ligne par moteur, une cellule
|
| 19 |
+
colorée par cellule. Une seconde ligne donne le score ``value``
|
| 20 |
+
(en plus petit). Catégorie omise si **aucun** moteur n'a de
|
| 21 |
+
GT exploitable pour elle.
|
| 22 |
+
|
| 23 |
+
Adaptative : ``""`` si aucun moteur n'a de
|
| 24 |
+
``aggregated_numerical_sequences``.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
from html import escape as _e
|
| 30 |
+
from typing import Optional
|
| 31 |
+
|
| 32 |
+
from picarones.evaluation.metrics.numerical_sequences import CATEGORIES
|
| 33 |
+
from picarones.reports_v2._helpers.render_helpers import color_traffic_light
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _category_columns_with_signal(rows: list[dict]) -> list[str]:
|
| 37 |
+
"""Ne garde que les catégories où ≥ 1 moteur a un n_total > 0."""
|
| 38 |
+
visible: list[str] = []
|
| 39 |
+
for cat in CATEGORIES:
|
| 40 |
+
for r in rows:
|
| 41 |
+
agg = r.get("aggregated_numerical_sequences") or {}
|
| 42 |
+
cat_data = (agg.get("per_category") or {}).get(cat) or {}
|
| 43 |
+
if (cat_data.get("n_total") or 0) > 0:
|
| 44 |
+
visible.append(cat)
|
| 45 |
+
break
|
| 46 |
+
return visible
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def build_numerical_sequences_html(
|
| 50 |
+
engines: list[dict],
|
| 51 |
+
labels: Optional[dict[str, str]] = None,
|
| 52 |
+
) -> str:
|
| 53 |
+
"""Construit la section HTML séquences numériques.
|
| 54 |
+
|
| 55 |
+
Returns
|
| 56 |
+
-------
|
| 57 |
+
str
|
| 58 |
+
``""`` si aucun moteur n'a de signal.
|
| 59 |
+
"""
|
| 60 |
+
rows = [
|
| 61 |
+
e for e in engines
|
| 62 |
+
if isinstance(e.get("aggregated_numerical_sequences"), dict)
|
| 63 |
+
]
|
| 64 |
+
if not rows:
|
| 65 |
+
return ""
|
| 66 |
+
visible_cats = _category_columns_with_signal(rows)
|
| 67 |
+
if not visible_cats:
|
| 68 |
+
return ""
|
| 69 |
+
labels = labels or {}
|
| 70 |
+
title = labels.get(
|
| 71 |
+
"numseq_title", "Précision sur séquences numériques",
|
| 72 |
+
)
|
| 73 |
+
note = labels.get(
|
| 74 |
+
"numseq_note",
|
| 75 |
+
"Score strict (forme préservée) — la valeur entre "
|
| 76 |
+
"parenthèses est le score sur la valeur (XIV ↔ 14 "
|
| 77 |
+
"accepté). Foliotation : recto/verso non interchangeables.",
|
| 78 |
+
)
|
| 79 |
+
col_engine = labels.get("numseq_engine", "Moteur")
|
| 80 |
+
col_global = labels.get("numseq_global", "Global")
|
| 81 |
+
cat_label = {
|
| 82 |
+
"year": labels.get("numseq_cat_year", "Année"),
|
| 83 |
+
"roman": labels.get("numseq_cat_roman", "Romain"),
|
| 84 |
+
"foliation": labels.get("numseq_cat_foliation", "Foliation"),
|
| 85 |
+
"currency": labels.get("numseq_cat_currency", "Montant"),
|
| 86 |
+
"regnal": labels.get("numseq_cat_regnal", "Régnal"),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
parts = [
|
| 90 |
+
'<div class="numseq-section" style="margin:1rem 0">',
|
| 91 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 92 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
|
| 93 |
+
f'{_e(note)}</div>',
|
| 94 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 95 |
+
'font-size:.9rem">',
|
| 96 |
+
'<thead><tr>',
|
| 97 |
+
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
|
| 98 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 99 |
+
f'{_e(col_engine)}</th>',
|
| 100 |
+
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
|
| 101 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 102 |
+
f'{_e(col_global)}</th>',
|
| 103 |
+
]
|
| 104 |
+
for cat in visible_cats:
|
| 105 |
+
parts.append(
|
| 106 |
+
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
|
| 107 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 108 |
+
f'{_e(cat_label.get(cat, cat))}</th>'
|
| 109 |
+
)
|
| 110 |
+
parts.append("</tr></thead><tbody>")
|
| 111 |
+
|
| 112 |
+
for engine in rows:
|
| 113 |
+
agg = engine["aggregated_numerical_sequences"]
|
| 114 |
+
name = engine.get("name") or "?"
|
| 115 |
+
per_cat = agg.get("per_category") or {}
|
| 116 |
+
global_strict = float(agg.get("global_strict_score") or 0.0)
|
| 117 |
+
global_value = float(agg.get("global_value_score") or 0.0)
|
| 118 |
+
n_total = int(agg.get("n_total") or 0)
|
| 119 |
+
global_color = color_traffic_light(global_strict)
|
| 120 |
+
parts.append(
|
| 121 |
+
f'<tr>'
|
| 122 |
+
f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
|
| 123 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 124 |
+
f'background:{global_color};font-family:monospace;'
|
| 125 |
+
f'font-weight:600">'
|
| 126 |
+
f'{global_strict * 100:.1f}%'
|
| 127 |
+
f'<span style="font-size:.75rem;font-weight:400;'
|
| 128 |
+
f'opacity:.75"> ({global_value * 100:.0f}%, '
|
| 129 |
+
f'n={n_total})</span></td>'
|
| 130 |
+
)
|
| 131 |
+
for cat in visible_cats:
|
| 132 |
+
cat_data = per_cat.get(cat) or {}
|
| 133 |
+
n = int(cat_data.get("n_total") or 0)
|
| 134 |
+
if n == 0:
|
| 135 |
+
parts.append(
|
| 136 |
+
'<td style="padding:.4rem .6rem;text-align:right;'
|
| 137 |
+
'opacity:.4">—</td>'
|
| 138 |
+
)
|
| 139 |
+
continue
|
| 140 |
+
strict = float(cat_data.get("strict_score") or 0.0)
|
| 141 |
+
value = float(cat_data.get("value_score") or 0.0)
|
| 142 |
+
color = color_traffic_light(strict)
|
| 143 |
+
parts.append(
|
| 144 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 145 |
+
f'background:{color};font-family:monospace">'
|
| 146 |
+
f'{strict * 100:.0f}%'
|
| 147 |
+
f'<span style="font-size:.75rem;opacity:.75"> '
|
| 148 |
+
f'({value * 100:.0f}%, n={n})</span></td>'
|
| 149 |
+
)
|
| 150 |
+
parts.append("</tr>")
|
| 151 |
+
parts.append("</tbody></table></div>")
|
| 152 |
+
return "".join(parts)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
__all__ = ["build_numerical_sequences_html"]
|
|
@@ -0,0 +1,713 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML server-side d'un benchmark de pipeline composée
|
| 2 |
+
(Sprint 67).
|
| 3 |
+
|
| 4 |
+
Phase 5.C.batch7 — module relocalisé depuis
|
| 5 |
+
``picarones.report.pipeline_render`` vers
|
| 6 |
+
``picarones.reports_v2.html.renderers.pipeline``. Le chemin legacy
|
| 7 |
+
reste disponible via un shim avec ``DeprecationWarning`` ;
|
| 8 |
+
suppression prévue en 2.0.
|
| 9 |
+
|
| 10 |
+
Suite directe Sprints 63-66 (axe B) — produit les blocs HTML qui
|
| 11 |
+
exposent le résultat d'une pipeline composée.
|
| 12 |
+
|
| 13 |
+
Pattern identique aux Sprints 41 (NER), 43 (calibration) et 62
|
| 14 |
+
(philologie) : rendu **server-side**, pas de JavaScript,
|
| 15 |
+
déterministe, anti-injection systématique via ``html.escape``.
|
| 16 |
+
|
| 17 |
+
Vue distincte du rapport OCR historique
|
| 18 |
+
---------------------------------------
|
| 19 |
+
Le rapport HTML OCR (``picarones/report/generator.py``) attend un
|
| 20 |
+
``BenchmarkResult`` (axe A). Pour les pipelines composées, on
|
| 21 |
+
travaille avec ``PipelineBenchmarkResult`` (axe B, Sprint 64).
|
| 22 |
+
|
| 23 |
+
Ce module fournit donc un rapport **autonome** : la fonction
|
| 24 |
+
``build_pipeline_report_html`` produit un document HTML complet
|
| 25 |
+
(``<!doctype html>...``) que l'utilisateur peut écrire directement
|
| 26 |
+
sur disque, sans dépendre du générateur OCR.
|
| 27 |
+
|
| 28 |
+
Sprint 67 — périmètre
|
| 29 |
+
---------------------
|
| 30 |
+
Inclus :
|
| 31 |
+
|
| 32 |
+
- ``build_pipeline_summary_html(bench)`` — encart résumé global
|
| 33 |
+
(corpus, n_docs, taux de succès, durée totale).
|
| 34 |
+
- ``build_pipeline_steps_table_html(bench)`` — tableau par étape
|
| 35 |
+
(durée mean/median, n_succeeded/failed, error_breakdown,
|
| 36 |
+
métriques aux jonctions).
|
| 37 |
+
- ``build_pipeline_report_html(bench, lang)`` — document HTML
|
| 38 |
+
complet à sauver sur disque.
|
| 39 |
+
|
| 40 |
+
Reporté à Sprint 68 :
|
| 41 |
+
|
| 42 |
+
- Rendu d'un ``PipelineComparisonResult`` (ranking entre N
|
| 43 |
+
pipelines + gain table).
|
| 44 |
+
|
| 45 |
+
Toujours pas de classification automatique
|
| 46 |
+
------------------------------------------
|
| 47 |
+
On affiche les chiffres bruts ; le chercheur lit et conclut.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
from __future__ import annotations
|
| 51 |
+
|
| 52 |
+
from dataclasses import dataclass
|
| 53 |
+
from html import escape as _e
|
| 54 |
+
from typing import Optional
|
| 55 |
+
|
| 56 |
+
from picarones.domain.artifacts import ArtifactType
|
| 57 |
+
from picarones.evaluation.pipeline_benchmark import PipelineBenchmarkResult
|
| 58 |
+
from picarones.evaluation.pipeline_comparison import PipelineComparisonResult
|
| 59 |
+
from picarones.reports_v2._helpers.render_helpers import color_traffic_light
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 63 |
+
# Helpers communs
|
| 64 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _format_duration(seconds: float) -> str:
|
| 68 |
+
"""Formate une durée en ms si < 1s, en s sinon."""
|
| 69 |
+
if seconds < 1.0:
|
| 70 |
+
return f"{seconds * 1000:.1f} ms"
|
| 71 |
+
if seconds < 60.0:
|
| 72 |
+
return f"{seconds:.2f} s"
|
| 73 |
+
minutes = int(seconds // 60)
|
| 74 |
+
rest = seconds - minutes * 60
|
| 75 |
+
return f"{minutes}min {rest:.1f}s"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 79 |
+
# Encart résumé corpus-wide
|
| 80 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def build_pipeline_summary_html(
|
| 84 |
+
bench: PipelineBenchmarkResult,
|
| 85 |
+
labels: Optional[dict[str, str]] = None,
|
| 86 |
+
) -> str:
|
| 87 |
+
"""Construit l'encart résumé global du benchmark."""
|
| 88 |
+
labels = labels or {}
|
| 89 |
+
title = labels.get("pipeline_summary_title", "Résumé du benchmark")
|
| 90 |
+
pipeline_label = labels.get("pipeline_name_label", "Pipeline")
|
| 91 |
+
corpus_label = labels.get("pipeline_corpus_label", "Corpus")
|
| 92 |
+
n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
|
| 93 |
+
succeeded_label = labels.get(
|
| 94 |
+
"pipeline_succeeded_label", "Pipelines réussies",
|
| 95 |
+
)
|
| 96 |
+
failed_label = labels.get("pipeline_failed_label", "Pipelines échouées")
|
| 97 |
+
duration_label = labels.get("pipeline_duration_label", "Durée totale")
|
| 98 |
+
|
| 99 |
+
success = bench.n_pipelines_succeeded
|
| 100 |
+
failed = bench.n_pipelines_failed
|
| 101 |
+
total = bench.n_docs
|
| 102 |
+
rate = success / total if total > 0 else 0.0
|
| 103 |
+
color = color_traffic_light(rate)
|
| 104 |
+
|
| 105 |
+
parts = [
|
| 106 |
+
'<div class="pipeline-summary" '
|
| 107 |
+
'style="margin:1rem 0;padding:.75rem;'
|
| 108 |
+
'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
|
| 109 |
+
f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
|
| 110 |
+
'<table style="border-collapse:collapse;font-size:.9rem">',
|
| 111 |
+
]
|
| 112 |
+
rows = [
|
| 113 |
+
(pipeline_label, _e(bench.pipeline_name)),
|
| 114 |
+
(corpus_label, _e(bench.corpus_name)),
|
| 115 |
+
(n_docs_label, str(total)),
|
| 116 |
+
(
|
| 117 |
+
succeeded_label,
|
| 118 |
+
f'<span style="background:{color};padding:.1rem .4rem;'
|
| 119 |
+
f'border-radius:3px">{success} / {total}</span>',
|
| 120 |
+
),
|
| 121 |
+
(failed_label, str(failed)),
|
| 122 |
+
(duration_label, _e(_format_duration(bench.total_duration_seconds))),
|
| 123 |
+
]
|
| 124 |
+
for label, value in rows:
|
| 125 |
+
parts.append(
|
| 126 |
+
f'<tr>'
|
| 127 |
+
f'<td style="padding:.2rem .5rem;font-weight:500;'
|
| 128 |
+
f'color:#555">{_e(label)}</td>'
|
| 129 |
+
f'<td style="padding:.2rem .5rem">{value}</td>'
|
| 130 |
+
f'</tr>'
|
| 131 |
+
)
|
| 132 |
+
parts.append("</table></div>")
|
| 133 |
+
return "".join(parts)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 137 |
+
# Tableau par étape
|
| 138 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def build_pipeline_steps_table_html(
|
| 142 |
+
bench: PipelineBenchmarkResult,
|
| 143 |
+
labels: Optional[dict[str, str]] = None,
|
| 144 |
+
) -> str:
|
| 145 |
+
"""Construit le tableau par étape de la pipeline.
|
| 146 |
+
|
| 147 |
+
Colonnes : nom de l'étape, n_succeeded, n_failed, taux de
|
| 148 |
+
succès (cellule colorée), durée mean/median, métriques aux
|
| 149 |
+
jonctions (mean) regroupées par type, error_breakdown
|
| 150 |
+
catégorisé.
|
| 151 |
+
"""
|
| 152 |
+
if not bench.per_step_aggregates:
|
| 153 |
+
return ""
|
| 154 |
+
labels = labels or {}
|
| 155 |
+
title = labels.get("pipeline_steps_title", "Détail par étape")
|
| 156 |
+
name_label = labels.get("pipeline_step_name_label", "Étape")
|
| 157 |
+
succ_label = labels.get("pipeline_succeeded_label", "Réussies")
|
| 158 |
+
fail_label = labels.get("pipeline_failed_label", "Échouées")
|
| 159 |
+
rate_label = labels.get("pipeline_success_rate_label", "Taux succès")
|
| 160 |
+
dmean_label = labels.get("pipeline_duration_mean_label", "Durée moyenne")
|
| 161 |
+
dmedian_label = labels.get(
|
| 162 |
+
"pipeline_duration_median_label", "Durée médiane",
|
| 163 |
+
)
|
| 164 |
+
metrics_label = labels.get(
|
| 165 |
+
"pipeline_junction_metrics_label", "Métriques aux jonctions",
|
| 166 |
+
)
|
| 167 |
+
errors_label = labels.get("pipeline_error_breakdown_label", "Erreurs")
|
| 168 |
+
|
| 169 |
+
parts = [
|
| 170 |
+
'<div class="pipeline-steps" style="margin:1rem 0">',
|
| 171 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 172 |
+
'<table style="border-collapse:collapse;font-size:.85rem;'
|
| 173 |
+
'width:100%">',
|
| 174 |
+
'<thead><tr>',
|
| 175 |
+
]
|
| 176 |
+
for col in (
|
| 177 |
+
name_label, succ_label, fail_label, rate_label,
|
| 178 |
+
dmean_label, dmedian_label, metrics_label, errors_label,
|
| 179 |
+
):
|
| 180 |
+
parts.append(
|
| 181 |
+
f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
|
| 182 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 183 |
+
f'{_e(col)}</th>'
|
| 184 |
+
)
|
| 185 |
+
parts.append("</tr></thead><tbody>")
|
| 186 |
+
|
| 187 |
+
for agg in bench.per_step_aggregates:
|
| 188 |
+
rate = agg.success_rate
|
| 189 |
+
rate_color = color_traffic_light(rate)
|
| 190 |
+
# Métriques aux jonctions : pour chaque type d'artefact,
|
| 191 |
+
# liste des métriques mean
|
| 192 |
+
metrics_cells: list[str] = []
|
| 193 |
+
for at_value, type_metrics in sorted(agg.junction_metrics.items()):
|
| 194 |
+
type_str = _e(at_value)
|
| 195 |
+
for mname, stats in sorted(type_metrics.items()):
|
| 196 |
+
mean = stats["mean"]
|
| 197 |
+
n = stats["n"]
|
| 198 |
+
metrics_cells.append(
|
| 199 |
+
f'<div style="font-size:.8rem;line-height:1.3">'
|
| 200 |
+
f'<code>{type_str}.{_e(mname)}</code>: '
|
| 201 |
+
f'{mean:.3f} '
|
| 202 |
+
f'<span style="opacity:.6">(n={n})</span></div>'
|
| 203 |
+
)
|
| 204 |
+
metrics_html = "".join(metrics_cells) or (
|
| 205 |
+
'<span style="opacity:.5">—</span>'
|
| 206 |
+
)
|
| 207 |
+
# Error breakdown
|
| 208 |
+
err_cells: list[str] = []
|
| 209 |
+
for label, count in sorted(agg.error_breakdown.items()):
|
| 210 |
+
err_cells.append(
|
| 211 |
+
f'<div style="font-size:.8rem;line-height:1.3">'
|
| 212 |
+
f'<code>{_e(label)}</code>: {count}</div>'
|
| 213 |
+
)
|
| 214 |
+
err_html = "".join(err_cells) or (
|
| 215 |
+
'<span style="opacity:.5">—</span>'
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
parts.append(
|
| 219 |
+
f'<tr>'
|
| 220 |
+
f'<td style="padding:.3rem .5rem;font-weight:500">'
|
| 221 |
+
f'{_e(agg.step_name)}</td>'
|
| 222 |
+
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 223 |
+
f'{agg.n_succeeded}</td>'
|
| 224 |
+
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 225 |
+
f'{agg.n_failed}</td>'
|
| 226 |
+
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 227 |
+
f'background:{rate_color}">{rate * 100:.0f}%</td>'
|
| 228 |
+
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 229 |
+
f'{_e(_format_duration(agg.duration_seconds_mean))}</td>'
|
| 230 |
+
f'<td style="padding:.3rem .5rem;text-align:right">'
|
| 231 |
+
f'{_e(_format_duration(agg.duration_seconds_median))}</td>'
|
| 232 |
+
f'<td style="padding:.3rem .5rem">{metrics_html}</td>'
|
| 233 |
+
f'<td style="padding:.3rem .5rem">{err_html}</td>'
|
| 234 |
+
f'</tr>'
|
| 235 |
+
)
|
| 236 |
+
parts.append("</tbody></table></div>")
|
| 237 |
+
return "".join(parts)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 241 |
+
# Document HTML autonome
|
| 242 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
_DOC_STYLES = """
|
| 246 |
+
:root {
|
| 247 |
+
--bg-primary: #ffffff;
|
| 248 |
+
--bg-secondary: #f7f7f7;
|
| 249 |
+
--text-primary: #222;
|
| 250 |
+
--text-muted: #666;
|
| 251 |
+
--border: #ddd;
|
| 252 |
+
}
|
| 253 |
+
* { box-sizing: border-box; }
|
| 254 |
+
body {
|
| 255 |
+
margin: 0;
|
| 256 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 257 |
+
background: var(--bg-primary);
|
| 258 |
+
color: var(--text-primary);
|
| 259 |
+
line-height: 1.5;
|
| 260 |
+
}
|
| 261 |
+
header {
|
| 262 |
+
padding: 1.5rem 2rem;
|
| 263 |
+
border-bottom: 1px solid var(--border);
|
| 264 |
+
}
|
| 265 |
+
header h1 { margin: 0 0 .3rem 0; font-size: 1.4rem; }
|
| 266 |
+
header .subtitle { color: var(--text-muted); font-size: .9rem; }
|
| 267 |
+
main { padding: 1rem 2rem 3rem 2rem; max-width: 1400px; margin: 0 auto; }
|
| 268 |
+
table { border: 1px solid var(--border); }
|
| 269 |
+
code { background: #f0f0f0; padding: 0 .2rem; border-radius: 2px; font-size: .85em; }
|
| 270 |
+
.note {
|
| 271 |
+
font-size: .85rem;
|
| 272 |
+
color: var(--text-muted);
|
| 273 |
+
font-style: italic;
|
| 274 |
+
margin: .5rem 0 1.5rem 0;
|
| 275 |
+
}
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def build_pipeline_report_html(
|
| 280 |
+
bench: PipelineBenchmarkResult,
|
| 281 |
+
labels: Optional[dict[str, str]] = None,
|
| 282 |
+
lang: str = "fr",
|
| 283 |
+
) -> str:
|
| 284 |
+
"""Construit un document HTML autonome pour un benchmark de
|
| 285 |
+
pipeline composée.
|
| 286 |
+
|
| 287 |
+
Le document est complet (``<!doctype html>...``) et peut être
|
| 288 |
+
sauvé directement sur disque par l'utilisateur :
|
| 289 |
+
|
| 290 |
+
>>> html = build_pipeline_report_html(bench)
|
| 291 |
+
>>> Path("rapport_pipeline.html").write_text(html)
|
| 292 |
+
"""
|
| 293 |
+
labels = labels or {}
|
| 294 |
+
main_title = labels.get(
|
| 295 |
+
"pipeline_report_title", "Rapport de pipeline composée",
|
| 296 |
+
)
|
| 297 |
+
note = labels.get(
|
| 298 |
+
"pipeline_report_note",
|
| 299 |
+
"Données brutes par étape. L'outil mesure et agrège — il "
|
| 300 |
+
"ne classe pas la pipeline « bonne » ou « mauvaise ». "
|
| 301 |
+
"C'est au chercheur de juger les chiffres selon ses critères.",
|
| 302 |
+
)
|
| 303 |
+
summary = build_pipeline_summary_html(bench, labels)
|
| 304 |
+
steps = build_pipeline_steps_table_html(bench, labels)
|
| 305 |
+
|
| 306 |
+
title_text = f"{main_title} — {bench.pipeline_name}"
|
| 307 |
+
parts = [
|
| 308 |
+
"<!doctype html>",
|
| 309 |
+
f'<html lang="{_e(lang)}">',
|
| 310 |
+
"<head>",
|
| 311 |
+
'<meta charset="utf-8">',
|
| 312 |
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
| 313 |
+
f"<title>{_e(title_text)}</title>",
|
| 314 |
+
"<style>", _DOC_STYLES, "</style>",
|
| 315 |
+
"</head>",
|
| 316 |
+
"<body>",
|
| 317 |
+
"<header>",
|
| 318 |
+
f"<h1>{_e(title_text)}</h1>",
|
| 319 |
+
f'<div class="subtitle">{_e(bench.corpus_name)} — '
|
| 320 |
+
f'{bench.n_docs} {_e(labels.get("pipeline_docs_short", "docs"))}'
|
| 321 |
+
f'</div>',
|
| 322 |
+
"</header>",
|
| 323 |
+
"<main>",
|
| 324 |
+
f'<p class="note">{_e(note)}</p>',
|
| 325 |
+
summary,
|
| 326 |
+
steps,
|
| 327 |
+
"</main>",
|
| 328 |
+
"</body>",
|
| 329 |
+
"</html>",
|
| 330 |
+
]
|
| 331 |
+
return "".join(parts)
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 335 |
+
# Sprint 68 — comparaison de N pipelines : ranking + gain table
|
| 336 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
@dataclass
|
| 340 |
+
class RankingSpec:
|
| 341 |
+
"""Spec d'un classement à afficher.
|
| 342 |
+
|
| 343 |
+
Décrit la jonction (``artifact_type``) et la métrique
|
| 344 |
+
(``metric_name``) à utiliser pour classer les pipelines.
|
| 345 |
+
|
| 346 |
+
Attributs
|
| 347 |
+
---------
|
| 348 |
+
artifact_type:
|
| 349 |
+
Type d'artefact où la métrique est calculée (typiquement
|
| 350 |
+
``ArtifactType.TEXT`` pour des métriques OCR).
|
| 351 |
+
metric_name:
|
| 352 |
+
Nom de la métrique dans le registre typé Sprint 34
|
| 353 |
+
(``"cer"``, ``"wer"``, ``"flesch_delta_fr"``, etc.).
|
| 354 |
+
higher_is_better:
|
| 355 |
+
``False`` (défaut) pour les métriques d'erreur (CER, WER) ;
|
| 356 |
+
``True`` pour les métriques de qualité (accuracy, F1,
|
| 357 |
+
coverage…).
|
| 358 |
+
label:
|
| 359 |
+
Libellé optionnel à afficher dans le tableau ; sinon
|
| 360 |
+
construit comme ``"<artifact_type>.<metric_name>"``.
|
| 361 |
+
"""
|
| 362 |
+
|
| 363 |
+
artifact_type: ArtifactType
|
| 364 |
+
metric_name: str
|
| 365 |
+
higher_is_better: bool = False
|
| 366 |
+
label: Optional[str] = None
|
| 367 |
+
|
| 368 |
+
@property
|
| 369 |
+
def display_label(self) -> str:
|
| 370 |
+
if self.label:
|
| 371 |
+
return self.label
|
| 372 |
+
return f"{self.artifact_type.value}.{self.metric_name}"
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
def _bg_for_rank(rank: int, total: int) -> str:
|
| 376 |
+
"""Gradient vert (rang 1) → rouge (dernier rang).
|
| 377 |
+
|
| 378 |
+
Mapping : ``rank ∈ [1, total]`` → ``color_traffic_light`` avec
|
| 379 |
+
``low_is_good=True`` (rang bas = bon).
|
| 380 |
+
"""
|
| 381 |
+
if total <= 1:
|
| 382 |
+
return color_traffic_light(1.0)
|
| 383 |
+
return color_traffic_light(
|
| 384 |
+
float(rank), low_is_good=True, scale_min=1.0, scale_max=float(total),
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def build_pipeline_ranking_table_html(
|
| 389 |
+
comparison: PipelineComparisonResult,
|
| 390 |
+
ranking_spec: RankingSpec,
|
| 391 |
+
labels: Optional[dict[str, str]] = None,
|
| 392 |
+
) -> str:
|
| 393 |
+
"""Tableau de classement des pipelines selon une métrique finale.
|
| 394 |
+
|
| 395 |
+
Colonnes : rang, nom du pipeline, valeur de la métrique (mean
|
| 396 |
+
sur le corpus à la dernière jonction qui produit
|
| 397 |
+
``artifact_type``). Les pipelines sans valeur sont listés en
|
| 398 |
+
queue avec un tiret.
|
| 399 |
+
"""
|
| 400 |
+
labels = labels or {}
|
| 401 |
+
title_template = labels.get(
|
| 402 |
+
"pipeline_ranking_title", "Classement par {label}",
|
| 403 |
+
)
|
| 404 |
+
title = title_template.format(label=ranking_spec.display_label)
|
| 405 |
+
rank_label = labels.get("pipeline_rank_label", "Rang")
|
| 406 |
+
name_label = labels.get("pipeline_name_label", "Pipeline")
|
| 407 |
+
value_label = labels.get("pipeline_value_label", "Valeur")
|
| 408 |
+
|
| 409 |
+
ranked = comparison.ranking_by_final_metric(
|
| 410 |
+
ranking_spec.artifact_type,
|
| 411 |
+
ranking_spec.metric_name,
|
| 412 |
+
higher_is_better=ranking_spec.higher_is_better,
|
| 413 |
+
)
|
| 414 |
+
if not ranked:
|
| 415 |
+
return ""
|
| 416 |
+
|
| 417 |
+
n_with_value = sum(1 for _name, v in ranked if v is not None)
|
| 418 |
+
|
| 419 |
+
parts = [
|
| 420 |
+
'<div class="pipeline-ranking" style="margin:1rem 0">',
|
| 421 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 422 |
+
'<table style="border-collapse:collapse;font-size:.85rem">',
|
| 423 |
+
'<thead><tr>',
|
| 424 |
+
]
|
| 425 |
+
for col in (rank_label, name_label, value_label):
|
| 426 |
+
parts.append(
|
| 427 |
+
f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
|
| 428 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 429 |
+
f'{_e(col)}</th>'
|
| 430 |
+
)
|
| 431 |
+
parts.append("</tr></thead><tbody>")
|
| 432 |
+
|
| 433 |
+
rank = 0
|
| 434 |
+
for name, value in ranked:
|
| 435 |
+
if value is None:
|
| 436 |
+
rank_str = "—"
|
| 437 |
+
value_str = "—"
|
| 438 |
+
rank_color = "#f0f0f0"
|
| 439 |
+
else:
|
| 440 |
+
rank += 1
|
| 441 |
+
rank_str = str(rank)
|
| 442 |
+
value_str = f"{value:.4f}"
|
| 443 |
+
rank_color = _bg_for_rank(rank, n_with_value)
|
| 444 |
+
parts.append(
|
| 445 |
+
f'<tr>'
|
| 446 |
+
f'<td style="padding:.3rem .5rem;text-align:center;'
|
| 447 |
+
f'background:{rank_color};font-weight:600">{rank_str}</td>'
|
| 448 |
+
f'<td style="padding:.3rem .5rem">{_e(name)}</td>'
|
| 449 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 450 |
+
f'font-family:monospace">{value_str}</td>'
|
| 451 |
+
f'</tr>'
|
| 452 |
+
)
|
| 453 |
+
parts.append("</tbody></table></div>")
|
| 454 |
+
return "".join(parts)
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def build_pipeline_gain_table_html(
|
| 458 |
+
comparison: PipelineComparisonResult,
|
| 459 |
+
ranking_spec: RankingSpec,
|
| 460 |
+
baseline_pipeline: str,
|
| 461 |
+
labels: Optional[dict[str, str]] = None,
|
| 462 |
+
) -> str:
|
| 463 |
+
"""Tableau gain vs baseline pour une métrique donnée.
|
| 464 |
+
|
| 465 |
+
Colonnes : pipeline, valeur, gain absolu, gain relatif. La
|
| 466 |
+
baseline est marquée explicitement (cellule grisée).
|
| 467 |
+
Convention de couleur : vert si gain favorable selon
|
| 468 |
+
``higher_is_better``, rouge sinon.
|
| 469 |
+
"""
|
| 470 |
+
labels = labels or {}
|
| 471 |
+
title_template = labels.get(
|
| 472 |
+
"pipeline_gain_title", "Gain vs {baseline} sur {label}",
|
| 473 |
+
)
|
| 474 |
+
title = title_template.format(
|
| 475 |
+
baseline=baseline_pipeline,
|
| 476 |
+
label=ranking_spec.display_label,
|
| 477 |
+
)
|
| 478 |
+
name_label = labels.get("pipeline_name_label", "Pipeline")
|
| 479 |
+
value_label = labels.get("pipeline_value_label", "Valeur")
|
| 480 |
+
abs_label = labels.get("pipeline_gain_absolute_label", "Gain absolu")
|
| 481 |
+
rel_label = labels.get("pipeline_gain_relative_label", "Gain relatif")
|
| 482 |
+
baseline_label = labels.get(
|
| 483 |
+
"pipeline_baseline_marker", "(référence)",
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
try:
|
| 487 |
+
gains = comparison.gain_table(
|
| 488 |
+
ranking_spec.artifact_type,
|
| 489 |
+
ranking_spec.metric_name,
|
| 490 |
+
baseline_pipeline,
|
| 491 |
+
)
|
| 492 |
+
except KeyError:
|
| 493 |
+
return ""
|
| 494 |
+
|
| 495 |
+
parts = [
|
| 496 |
+
'<div class="pipeline-gain" style="margin:1rem 0">',
|
| 497 |
+
f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
|
| 498 |
+
'<table style="border-collapse:collapse;font-size:.85rem">',
|
| 499 |
+
'<thead><tr>',
|
| 500 |
+
]
|
| 501 |
+
for col in (name_label, value_label, abs_label, rel_label):
|
| 502 |
+
parts.append(
|
| 503 |
+
f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
|
| 504 |
+
f'border-bottom:1px solid #ccc;font-weight:600">'
|
| 505 |
+
f'{_e(col)}</th>'
|
| 506 |
+
)
|
| 507 |
+
parts.append("</tr></thead><tbody>")
|
| 508 |
+
|
| 509 |
+
for name, g in gains.items():
|
| 510 |
+
is_baseline = name == baseline_pipeline
|
| 511 |
+
value = g["value"]
|
| 512 |
+
absolute = g["absolute"]
|
| 513 |
+
relative = g["relative"]
|
| 514 |
+
# Formatage des cellules
|
| 515 |
+
value_str = "—" if value is None else f"{value:.4f}"
|
| 516 |
+
abs_str = "—" if absolute is None else f"{absolute:+.4f}"
|
| 517 |
+
rel_str = "—" if relative is None else f"{relative * 100:+.1f}%"
|
| 518 |
+
# Couleur du gain : vert si favorable, rouge sinon, gris pour
|
| 519 |
+
# la baseline.
|
| 520 |
+
if is_baseline:
|
| 521 |
+
gain_color = "#f0f0f0"
|
| 522 |
+
elif absolute is None or absolute == 0:
|
| 523 |
+
gain_color = "#f0f0f0"
|
| 524 |
+
else:
|
| 525 |
+
favorable = (
|
| 526 |
+
absolute > 0 if ranking_spec.higher_is_better else absolute < 0
|
| 527 |
+
)
|
| 528 |
+
gain_color = "#cfe8cf" if favorable else "#f4cfcf"
|
| 529 |
+
# Marqueur baseline
|
| 530 |
+
name_cell = _e(name)
|
| 531 |
+
if is_baseline:
|
| 532 |
+
name_cell += (
|
| 533 |
+
f' <span style="opacity:.6;font-size:.85em">'
|
| 534 |
+
f'{_e(baseline_label)}</span>'
|
| 535 |
+
)
|
| 536 |
+
parts.append(
|
| 537 |
+
f'<tr>'
|
| 538 |
+
f'<td style="padding:.3rem .5rem;font-weight:500">{name_cell}</td>'
|
| 539 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 540 |
+
f'font-family:monospace">{value_str}</td>'
|
| 541 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 542 |
+
f'font-family:monospace;background:{gain_color}">{abs_str}</td>'
|
| 543 |
+
f'<td style="padding:.3rem .5rem;text-align:right;'
|
| 544 |
+
f'font-family:monospace;background:{gain_color}">{rel_str}</td>'
|
| 545 |
+
f'</tr>'
|
| 546 |
+
)
|
| 547 |
+
parts.append("</tbody></table></div>")
|
| 548 |
+
return "".join(parts)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def build_pipeline_comparison_summary_html(
|
| 552 |
+
comparison: PipelineComparisonResult,
|
| 553 |
+
labels: Optional[dict[str, str]] = None,
|
| 554 |
+
) -> str:
|
| 555 |
+
"""Encart de résumé global d'une comparaison de pipelines.
|
| 556 |
+
|
| 557 |
+
Affiche corpus, n_docs, durée totale, nombre de pipelines, et
|
| 558 |
+
pour chacune un mini-résumé n_succeeded / n_docs.
|
| 559 |
+
"""
|
| 560 |
+
labels = labels or {}
|
| 561 |
+
title = labels.get(
|
| 562 |
+
"pipeline_comparison_summary_title", "Résumé de la comparaison",
|
| 563 |
+
)
|
| 564 |
+
corpus_label = labels.get("pipeline_corpus_label", "Corpus")
|
| 565 |
+
n_docs_label = labels.get("pipeline_n_docs_label", "Documents")
|
| 566 |
+
n_pipelines_label = labels.get(
|
| 567 |
+
"pipeline_n_pipelines_label", "Pipelines comparées",
|
| 568 |
+
)
|
| 569 |
+
duration_label = labels.get("pipeline_duration_label", "Durée totale")
|
| 570 |
+
|
| 571 |
+
parts = [
|
| 572 |
+
'<div class="pipeline-comparison-summary" '
|
| 573 |
+
'style="margin:1rem 0;padding:.75rem;'
|
| 574 |
+
'background:var(--bg-secondary,#f7f7f7);border-radius:6px">',
|
| 575 |
+
f'<div style="font-weight:600;margin-bottom:.5rem">{_e(title)}</div>',
|
| 576 |
+
'<table style="border-collapse:collapse;font-size:.9rem">',
|
| 577 |
+
]
|
| 578 |
+
rows = [
|
| 579 |
+
(corpus_label, _e(comparison.corpus_name)),
|
| 580 |
+
(n_docs_label, str(comparison.n_docs)),
|
| 581 |
+
(n_pipelines_label, str(len(comparison.per_pipeline))),
|
| 582 |
+
(duration_label, _e(_format_duration(comparison.total_duration_seconds))),
|
| 583 |
+
]
|
| 584 |
+
for label, value in rows:
|
| 585 |
+
parts.append(
|
| 586 |
+
f'<tr>'
|
| 587 |
+
f'<td style="padding:.2rem .5rem;font-weight:500;color:#555">'
|
| 588 |
+
f'{_e(label)}</td>'
|
| 589 |
+
f'<td style="padding:.2rem .5rem">{value}</td>'
|
| 590 |
+
f'</tr>'
|
| 591 |
+
)
|
| 592 |
+
parts.append("</table>")
|
| 593 |
+
# Mini-résumé par pipeline
|
| 594 |
+
if comparison.per_pipeline:
|
| 595 |
+
per_pipeline_label = labels.get(
|
| 596 |
+
"pipeline_per_pipeline_label", "Par pipeline",
|
| 597 |
+
)
|
| 598 |
+
parts.append(
|
| 599 |
+
f'<div style="margin-top:.6rem;font-size:.85rem">'
|
| 600 |
+
f'<span style="font-weight:500;color:#555">'
|
| 601 |
+
f'{_e(per_pipeline_label)} :</span>'
|
| 602 |
+
)
|
| 603 |
+
items: list[str] = []
|
| 604 |
+
for name, bench in comparison.per_pipeline.items():
|
| 605 |
+
items.append(
|
| 606 |
+
f'<code>{_e(name)}</code> '
|
| 607 |
+
f'({bench.n_pipelines_succeeded}/{bench.n_docs})'
|
| 608 |
+
)
|
| 609 |
+
parts.append(" — ".join(items))
|
| 610 |
+
parts.append("</div>")
|
| 611 |
+
parts.append("</div>")
|
| 612 |
+
return "".join(parts)
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def build_pipeline_comparison_report_html(
|
| 616 |
+
comparison: PipelineComparisonResult,
|
| 617 |
+
ranking_specs: Optional[list[RankingSpec]] = None,
|
| 618 |
+
baseline_pipeline: Optional[str] = None,
|
| 619 |
+
labels: Optional[dict[str, str]] = None,
|
| 620 |
+
lang: str = "fr",
|
| 621 |
+
) -> str:
|
| 622 |
+
"""Document HTML autonome pour une comparaison de N pipelines.
|
| 623 |
+
|
| 624 |
+
Parameters
|
| 625 |
+
----------
|
| 626 |
+
comparison:
|
| 627 |
+
Résultat de ``compare_pipelines`` (Sprint 65).
|
| 628 |
+
ranking_specs:
|
| 629 |
+
Liste explicite des classements à afficher. Pour chaque
|
| 630 |
+
spec, on rend un tableau de classement et, si
|
| 631 |
+
``baseline_pipeline`` est fourni, un tableau de gain.
|
| 632 |
+
Si ``None`` ou vide, on affiche uniquement le résumé
|
| 633 |
+
global et les résumés par pipeline (sans verdict).
|
| 634 |
+
baseline_pipeline:
|
| 635 |
+
Pipeline de référence pour les tableaux de gain. Si
|
| 636 |
+
``None``, les tableaux de gain ne sont pas affichés.
|
| 637 |
+
labels:
|
| 638 |
+
Map i18n.
|
| 639 |
+
lang:
|
| 640 |
+
Code langue pour ``<html lang="…">``.
|
| 641 |
+
|
| 642 |
+
Returns
|
| 643 |
+
-------
|
| 644 |
+
str
|
| 645 |
+
Document HTML complet (``<!doctype html>`` + ``<html>``).
|
| 646 |
+
"""
|
| 647 |
+
labels = labels or {}
|
| 648 |
+
main_title = labels.get(
|
| 649 |
+
"pipeline_comparison_report_title",
|
| 650 |
+
"Rapport de comparaison de pipelines",
|
| 651 |
+
)
|
| 652 |
+
note = labels.get(
|
| 653 |
+
"pipeline_comparison_report_note",
|
| 654 |
+
"Données comparatives brutes. L'outil mesure et classe — il "
|
| 655 |
+
"ne tranche pas le débat éditorial. C'est au chercheur de "
|
| 656 |
+
"lire les chiffres et de conclure selon ses critères.",
|
| 657 |
+
)
|
| 658 |
+
title_text = f"{main_title} — {comparison.corpus_name}"
|
| 659 |
+
summary = build_pipeline_comparison_summary_html(comparison, labels)
|
| 660 |
+
|
| 661 |
+
rankings_html: list[str] = []
|
| 662 |
+
for spec in (ranking_specs or []):
|
| 663 |
+
rankings_html.append(
|
| 664 |
+
build_pipeline_ranking_table_html(comparison, spec, labels),
|
| 665 |
+
)
|
| 666 |
+
if baseline_pipeline is not None:
|
| 667 |
+
rankings_html.append(
|
| 668 |
+
build_pipeline_gain_table_html(
|
| 669 |
+
comparison, spec, baseline_pipeline, labels,
|
| 670 |
+
),
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
parts = [
|
| 674 |
+
"<!doctype html>",
|
| 675 |
+
f'<html lang="{_e(lang)}">',
|
| 676 |
+
"<head>",
|
| 677 |
+
'<meta charset="utf-8">',
|
| 678 |
+
'<meta name="viewport" content="width=device-width,initial-scale=1">',
|
| 679 |
+
f"<title>{_e(title_text)}</title>",
|
| 680 |
+
"<style>", _DOC_STYLES, "</style>",
|
| 681 |
+
"</head>",
|
| 682 |
+
"<body>",
|
| 683 |
+
"<header>",
|
| 684 |
+
f"<h1>{_e(title_text)}</h1>",
|
| 685 |
+
f'<div class="subtitle">{len(comparison.per_pipeline)} '
|
| 686 |
+
f'{_e(labels.get("pipeline_n_pipelines_short", "pipelines"))} '
|
| 687 |
+
f'— {comparison.n_docs} '
|
| 688 |
+
f'{_e(labels.get("pipeline_docs_short", "docs"))}'
|
| 689 |
+
f'</div>',
|
| 690 |
+
"</header>",
|
| 691 |
+
"<main>",
|
| 692 |
+
f'<p class="note">{_e(note)}</p>',
|
| 693 |
+
summary,
|
| 694 |
+
]
|
| 695 |
+
parts.extend(rankings_html)
|
| 696 |
+
parts.extend([
|
| 697 |
+
"</main>",
|
| 698 |
+
"</body>",
|
| 699 |
+
"</html>",
|
| 700 |
+
])
|
| 701 |
+
return "".join(parts)
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
__all__ = [
|
| 705 |
+
"build_pipeline_summary_html",
|
| 706 |
+
"build_pipeline_steps_table_html",
|
| 707 |
+
"build_pipeline_report_html",
|
| 708 |
+
"RankingSpec",
|
| 709 |
+
"build_pipeline_ranking_table_html",
|
| 710 |
+
"build_pipeline_gain_table_html",
|
| 711 |
+
"build_pipeline_comparison_summary_html",
|
| 712 |
+
"build_pipeline_comparison_report_html",
|
| 713 |
+
]
|
|
@@ -52,7 +52,9 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 52 |
"picarones/report/generator.py": 500, # actuel 431
|
| 53 |
# --- Fichiers métier larges.
|
| 54 |
"picarones/measurements/robustness.py": 850, # actuel 731
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
# Phase 4-ter : ``core/results.py`` est désormais un shim
|
| 57 |
# (≤ 25 l). Le contenu canonique vit dans ``evaluation/`` ;
|
| 58 |
# même budget pour la même raison historique (modèles
|
|
@@ -65,7 +67,9 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 65 |
"picarones/measurements/history.py": 725, # actuel 615
|
| 66 |
"picarones/measurements/modern_archives.py": 700, # actuel 599
|
| 67 |
"picarones/measurements/builtin_hooks.py": 700, # actuel 590
|
| 68 |
-
|
|
|
|
|
|
|
| 69 |
"picarones/extras/importers/iiif.py": 675, # actuel 567
|
| 70 |
"picarones/extras/importers/gallica.py": 675, # actuel 563
|
| 71 |
"picarones/measurements/levers.py": 675, # actuel 561 (re-export S10)
|
|
@@ -115,7 +119,10 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 115 |
"picarones/evaluation/corpus.py": 600, # actuel 533
|
| 116 |
"picarones/fixtures.py": 600, # actuel 510
|
| 117 |
"picarones/measurements/inter_engine.py": 575, # actuel 484
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
| 119 |
"picarones/extras/importers/htr_united.py": 575, # actuel 473 (re-export S11)
|
| 120 |
# Sprint A14-S11 — d\xc3\xa9plac\xc3\xa9s depuis extras/importers/, l'ancien
|
| 121 |
# emplacement est d\xc3\xa9sormais un re-export.
|
|
@@ -128,7 +135,10 @@ FILE_BUDGETS: dict[str, int] = {
|
|
| 128 |
# même budget pour la même raison historique (centralise les
|
| 129 |
# hooks document/corpus, croissance maîtrisée).
|
| 130 |
"picarones/evaluation/metric_hooks.py": 500, # actuel 427
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 132 |
"picarones/measurements/normalization.py": 500, # actuel 420 (re-export S9)
|
| 133 |
# Sprint A14-S9 — déplacé depuis measurements/normalization.py.
|
| 134 |
# L'ancien emplacement est désormais un re-export ; le contenu
|
|
|
|
| 52 |
"picarones/report/generator.py": 500, # actuel 431
|
| 53 |
# --- Fichiers métier larges.
|
| 54 |
"picarones/measurements/robustness.py": 850, # actuel 731
|
| 55 |
+
# Phase 5.C.batch7 : ``report/pipeline_render.py`` est désormais
|
| 56 |
+
# un shim ; canonique dans ``reports_v2/html/renderers/pipeline.py``.
|
| 57 |
+
"picarones/reports_v2/html/renderers/pipeline.py": 815, # actuel 713
|
| 58 |
# Phase 4-ter : ``core/results.py`` est désormais un shim
|
| 59 |
# (≤ 25 l). Le contenu canonique vit dans ``evaluation/`` ;
|
| 60 |
# même budget pour la même raison historique (modèles
|
|
|
|
| 67 |
"picarones/measurements/history.py": 725, # actuel 615
|
| 68 |
"picarones/measurements/modern_archives.py": 700, # actuel 599
|
| 69 |
"picarones/measurements/builtin_hooks.py": 700, # actuel 590
|
| 70 |
+
# Phase 5.C.batch7 : ``core/pipeline.py`` est désormais un shim ;
|
| 71 |
+
# canonique dans ``evaluation/pipeline.py``.
|
| 72 |
+
"picarones/evaluation/pipeline.py": 700, # actuel 622
|
| 73 |
"picarones/extras/importers/iiif.py": 675, # actuel 567
|
| 74 |
"picarones/extras/importers/gallica.py": 675, # actuel 563
|
| 75 |
"picarones/measurements/levers.py": 675, # actuel 561 (re-export S10)
|
|
|
|
| 119 |
"picarones/evaluation/corpus.py": 600, # actuel 533
|
| 120 |
"picarones/fixtures.py": 600, # actuel 510
|
| 121 |
"picarones/measurements/inter_engine.py": 575, # actuel 484
|
| 122 |
+
# Phase 5.C.batch7 : ``measurements/roman_numerals.py`` est
|
| 123 |
+
# désormais un shim ; canonique dans
|
| 124 |
+
# ``evaluation/metrics/roman_numerals.py``.
|
| 125 |
+
"picarones/evaluation/metrics/roman_numerals.py": 575, # actuel 484
|
| 126 |
"picarones/extras/importers/htr_united.py": 575, # actuel 473 (re-export S11)
|
| 127 |
# Sprint A14-S11 — d\xc3\xa9plac\xc3\xa9s depuis extras/importers/, l'ancien
|
| 128 |
# emplacement est d\xc3\xa9sormais un re-export.
|
|
|
|
| 135 |
# même budget pour la même raison historique (centralise les
|
| 136 |
# hooks document/corpus, croissance maîtrisée).
|
| 137 |
"picarones/evaluation/metric_hooks.py": 500, # actuel 427
|
| 138 |
+
# Phase 5.C.batch7 : ``measurements/numerical_sequences.py`` est
|
| 139 |
+
# désormais un shim ; canonique dans
|
| 140 |
+
# ``evaluation/metrics/numerical_sequences.py``.
|
| 141 |
+
"picarones/evaluation/metrics/numerical_sequences.py": 500, # actuel 428
|
| 142 |
"picarones/measurements/normalization.py": 500, # actuel 420 (re-export S9)
|
| 143 |
# Sprint A14-S9 — déplacé depuis measurements/normalization.py.
|
| 144 |
# L'ancien emplacement est désormais un re-export ; le contenu
|
|
@@ -73,6 +73,17 @@ TEST_ONLY_BASELINE: frozenset[str] = frozenset({
|
|
| 73 |
"specialization",
|
| 74 |
"lexical_modernization",
|
| 75 |
"robustness_projection",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
})
|
| 77 |
|
| 78 |
|
|
|
|
| 73 |
"specialization",
|
| 74 |
"lexical_modernization",
|
| 75 |
"robustness_projection",
|
| 76 |
+
# Phase 5.C.batch7 : 4 modules supplémentaires migrés vers
|
| 77 |
+
# ``evaluation/`` (``numerical_sequences``,
|
| 78 |
+
# ``pipeline_benchmark``, ``pipeline_comparison``) ou
|
| 79 |
+
# ``evaluation/metrics/`` (``numerical_sequences``).
|
| 80 |
+
# ``numerical_sequences_hooks`` n'est plus consommé en prod
|
| 81 |
+
# car son seul consommateur (le renderer) consomme désormais
|
| 82 |
+
# le canonique.
|
| 83 |
+
"numerical_sequences",
|
| 84 |
+
"numerical_sequences_hooks",
|
| 85 |
+
"pipeline_benchmark",
|
| 86 |
+
"pipeline_comparison",
|
| 87 |
})
|
| 88 |
|
| 89 |
|
|
@@ -420,25 +420,26 @@ class TestCercle1IsLean:
|
|
| 420 |
# Tout module avec de la logique métier (calcul, orchestration)
|
| 421 |
# appartient au Cercle 2 (``measurements/``) ou au Cercle 3
|
| 422 |
# (``extras/``, ``report/``).
|
| 423 |
-
EXPECTED_CERCLE1 =
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
|
|
|
| 442 |
|
| 443 |
def test_cercle1_files_lean(self):
|
| 444 |
from pathlib import Path
|
|
|
|
| 420 |
# Tout module avec de la logique métier (calcul, orchestration)
|
| 421 |
# appartient au Cercle 2 (``measurements/``) ou au Cercle 3
|
| 422 |
# (``extras/``, ``report/``).
|
| 423 |
+
EXPECTED_CERCLE1: set[str] = set()
|
| 424 |
+
# Phase 1 du retrait du legacy a déplacé `facts.py`,
|
| 425 |
+
# `diff_utils.py` et `xml_utils.py` vers leurs canoniques
|
| 426 |
+
# (`domain/facts.py`, `evaluation/_diff_utils.py`,
|
| 427 |
+
# `formats/_xml_utils.py`). Les fichiers `core/X.py`
|
| 428 |
+
# restent comme shims re-export avec DeprecationWarning
|
| 429 |
+
# (< 30 lignes), donc ne comptent plus comme "real_modules"
|
| 430 |
+
# au sens de ce test.
|
| 431 |
+
# Phase 4-bis a fait pareil pour `modules.py` (canonique :
|
| 432 |
+
# `domain/module_protocol.py` + `domain/artifacts.py`).
|
| 433 |
+
# Phase 4-ter a fait pareil pour `metric_registry.py`,
|
| 434 |
+
# `metric_hooks.py` (canonique : `evaluation/metric_*.py`),
|
| 435 |
+
# `metrics.py` (canonique : `evaluation/metric_result.py`)
|
| 436 |
+
# et `results.py` (canonique :
|
| 437 |
+
# `evaluation/benchmark_result.py`).
|
| 438 |
+
# Phase 4-quater a fait pareil pour `corpus.py`
|
| 439 |
+
# (canonique : `evaluation/corpus.py`).
|
| 440 |
+
# Phase 5.C.batch7 a fait pareil pour `pipeline.py`
|
| 441 |
+
# (canonique : `evaluation/pipeline.py`). Désormais
|
| 442 |
+
# ``core/`` ne contient plus que des shims < 30 lignes.
|
| 443 |
|
| 444 |
def test_cercle1_files_lean(self):
|
| 445 |
from pathlib import Path
|
|
@@ -28,7 +28,7 @@ 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.
|
| 32 |
PipelineResult,
|
| 33 |
PipelineRunner,
|
| 34 |
PipelineSpec,
|
|
|
|
| 28 |
|
| 29 |
from picarones.core.corpus import Document, GTLevel, TextGT
|
| 30 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 31 |
+
from picarones.evaluation.pipeline import (
|
| 32 |
PipelineResult,
|
| 33 |
PipelineRunner,
|
| 34 |
PipelineSpec,
|
|
@@ -32,7 +32,7 @@ from typing import Any
|
|
| 32 |
|
| 33 |
from picarones.core.corpus import Document, GTLevel, TextGT
|
| 34 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 35 |
-
from picarones.
|
| 36 |
PipelineRunner,
|
| 37 |
PipelineSpec,
|
| 38 |
PipelineStep,
|
|
|
|
| 32 |
|
| 33 |
from picarones.core.corpus import Document, GTLevel, TextGT
|
| 34 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 35 |
+
from picarones.evaluation.pipeline import (
|
| 36 |
PipelineRunner,
|
| 37 |
PipelineSpec,
|
| 38 |
PipelineStep,
|
|
@@ -29,7 +29,7 @@ from picarones.measurements.alto_metrics import (
|
|
| 29 |
from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
|
| 30 |
from picarones.core.metric_registry import compute_at_junction, select_metrics
|
| 31 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 32 |
-
from picarones.
|
| 33 |
PipelineRunner,
|
| 34 |
PipelineSpec,
|
| 35 |
PipelineStep,
|
|
|
|
| 29 |
from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
|
| 30 |
from picarones.core.metric_registry import compute_at_junction, select_metrics
|
| 31 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 32 |
+
from picarones.evaluation.pipeline import (
|
| 33 |
PipelineRunner,
|
| 34 |
PipelineSpec,
|
| 35 |
PipelineStep,
|
|
@@ -34,7 +34,7 @@ import pytest
|
|
| 34 |
from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
|
| 35 |
from picarones.core.metric_registry import select_metrics
|
| 36 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 37 |
-
from picarones.
|
| 38 |
PipelineRunner,
|
| 39 |
PipelineSpec,
|
| 40 |
PipelineStep,
|
|
|
|
| 34 |
from picarones.core.corpus import AltoGT, Document, GTLevel, TextGT
|
| 35 |
from picarones.core.metric_registry import select_metrics
|
| 36 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 37 |
+
from picarones.evaluation.pipeline import (
|
| 38 |
PipelineRunner,
|
| 39 |
PipelineSpec,
|
| 40 |
PipelineStep,
|
|
@@ -153,7 +153,7 @@ class TestCodeSnippets:
|
|
| 153 |
# Les imports doivent pointer vers les vrais modules
|
| 154 |
# picarones.core.* et picarones.report.*
|
| 155 |
assert "from picarones.core.modules import" in doc
|
| 156 |
-
assert "from picarones.
|
| 157 |
-
assert "from picarones.
|
| 158 |
-
assert "from picarones.
|
| 159 |
-
assert "from picarones.
|
|
|
|
| 153 |
# Les imports doivent pointer vers les vrais modules
|
| 154 |
# picarones.core.* et picarones.report.*
|
| 155 |
assert "from picarones.core.modules import" in doc
|
| 156 |
+
assert "from picarones.evaluation.pipeline import" in doc
|
| 157 |
+
assert "from picarones.evaluation.pipeline_benchmark import" in doc
|
| 158 |
+
assert "from picarones.evaluation.pipeline_comparison import" in doc
|
| 159 |
+
assert "from picarones.reports_v2.html.renderers.pipeline import" in doc
|
|
@@ -23,7 +23,7 @@ import pytest
|
|
| 23 |
|
| 24 |
from picarones.core.metric_registry import compute_at_junction, select_metrics
|
| 25 |
from picarones.core.modules import ArtifactType
|
| 26 |
-
from picarones.
|
| 27 |
ALL_STATUSES,
|
| 28 |
STATUS_CASE_CHANGED,
|
| 29 |
STATUS_CONVERTED_TO_ARABIC,
|
|
|
|
| 23 |
|
| 24 |
from picarones.core.metric_registry import compute_at_junction, select_metrics
|
| 25 |
from picarones.core.modules import ArtifactType
|
| 26 |
+
from picarones.evaluation.metrics.roman_numerals import (
|
| 27 |
ALL_STATUSES,
|
| 28 |
STATUS_CASE_CHANGED,
|
| 29 |
STATUS_CONVERTED_TO_ARABIC,
|
|
@@ -31,13 +31,13 @@ from typing import Any
|
|
| 31 |
|
| 32 |
from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
|
| 33 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 34 |
-
from picarones.
|
| 35 |
PipelineBenchmarkResult,
|
| 36 |
StepAggregate,
|
| 37 |
default_initial_inputs,
|
| 38 |
run_pipeline_benchmark,
|
| 39 |
)
|
| 40 |
-
from picarones.
|
| 41 |
|
| 42 |
|
| 43 |
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
| 31 |
|
| 32 |
from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
|
| 33 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 34 |
+
from picarones.evaluation.pipeline_benchmark import (
|
| 35 |
PipelineBenchmarkResult,
|
| 36 |
StepAggregate,
|
| 37 |
default_initial_inputs,
|
| 38 |
run_pipeline_benchmark,
|
| 39 |
)
|
| 40 |
+
from picarones.evaluation.pipeline import PipelineSpec, PipelineStep
|
| 41 |
|
| 42 |
|
| 43 |
# ──────────────────────────────────────────────────────────────────────────
|
|
@@ -33,11 +33,11 @@ import pytest
|
|
| 33 |
|
| 34 |
from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
|
| 35 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 36 |
-
from picarones.
|
| 37 |
PipelineComparisonResult,
|
| 38 |
compare_pipelines,
|
| 39 |
)
|
| 40 |
-
from picarones.
|
| 41 |
|
| 42 |
|
| 43 |
# ──────────────────────────────────────────────────────────────────────────
|
|
|
|
| 33 |
|
| 34 |
from picarones.core.corpus import Corpus, Document, GTLevel, TextGT
|
| 35 |
from picarones.core.modules import ArtifactType, BaseModule
|
| 36 |
+
from picarones.evaluation.pipeline_comparison import (
|
| 37 |
PipelineComparisonResult,
|
| 38 |
compare_pipelines,
|
| 39 |
)
|
| 40 |
+
from picarones.evaluation.pipeline import PipelineSpec, PipelineStep
|
| 41 |
|
| 42 |
|
| 43 |
# ──────────────────────────────────────────────────────────────────────────
|
|
@@ -16,7 +16,7 @@ Couvre :
|
|
| 16 |
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
-
from picarones.
|
| 20 |
CATEGORIES,
|
| 21 |
_detect_currencies,
|
| 22 |
_detect_foliations,
|
|
|
|
| 16 |
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
+
from picarones.evaluation.metrics.numerical_sequences import (
|
| 20 |
CATEGORIES,
|
| 21 |
_detect_currencies,
|
| 22 |
_detect_foliations,
|
|
@@ -21,11 +21,11 @@ from __future__ import annotations
|
|
| 21 |
import json
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
-
from picarones.
|
| 25 |
PipelineBenchmarkResult,
|
| 26 |
StepAggregate,
|
| 27 |
)
|
| 28 |
-
from picarones.
|
| 29 |
build_pipeline_report_html,
|
| 30 |
build_pipeline_steps_table_html,
|
| 31 |
build_pipeline_summary_html,
|
|
|
|
| 21 |
import json
|
| 22 |
from pathlib import Path
|
| 23 |
|
| 24 |
+
from picarones.evaluation.pipeline_benchmark import (
|
| 25 |
PipelineBenchmarkResult,
|
| 26 |
StepAggregate,
|
| 27 |
)
|
| 28 |
+
from picarones.reports_v2.html.renderers.pipeline import (
|
| 29 |
build_pipeline_report_html,
|
| 30 |
build_pipeline_steps_table_html,
|
| 31 |
build_pipeline_summary_html,
|
|
@@ -32,12 +32,12 @@ import json
|
|
| 32 |
from pathlib import Path
|
| 33 |
|
| 34 |
from picarones.core.modules import ArtifactType
|
| 35 |
-
from picarones.
|
| 36 |
PipelineBenchmarkResult,
|
| 37 |
StepAggregate,
|
| 38 |
)
|
| 39 |
-
from picarones.
|
| 40 |
-
from picarones.
|
| 41 |
RankingSpec,
|
| 42 |
build_pipeline_comparison_report_html,
|
| 43 |
build_pipeline_comparison_summary_html,
|
|
|
|
| 32 |
from pathlib import Path
|
| 33 |
|
| 34 |
from picarones.core.modules import ArtifactType
|
| 35 |
+
from picarones.evaluation.pipeline_benchmark import (
|
| 36 |
PipelineBenchmarkResult,
|
| 37 |
StepAggregate,
|
| 38 |
)
|
| 39 |
+
from picarones.evaluation.pipeline_comparison import PipelineComparisonResult
|
| 40 |
+
from picarones.reports_v2.html.renderers.pipeline import (
|
| 41 |
RankingSpec,
|
| 42 |
build_pipeline_comparison_report_html,
|
| 43 |
build_pipeline_comparison_summary_html,
|
|
@@ -36,7 +36,7 @@ from picarones.measurements.searchability_hooks import (
|
|
| 36 |
aggregate_searchability_metrics,
|
| 37 |
compute_searchability_metrics,
|
| 38 |
)
|
| 39 |
-
from picarones.
|
| 40 |
build_numerical_sequences_html,
|
| 41 |
)
|
| 42 |
from picarones.reports_v2.html.renderers.searchability import (
|
|
|
|
| 36 |
aggregate_searchability_metrics,
|
| 37 |
compute_searchability_metrics,
|
| 38 |
)
|
| 39 |
+
from picarones.reports_v2.html.renderers.numerical_sequences import (
|
| 40 |
build_numerical_sequences_html,
|
| 41 |
)
|
| 42 |
from picarones.reports_v2.html.renderers.searchability import (
|