Spaces:
Sleeping
feat(pipeline): Sprint A14-S6 — PipelineSpec déclaratif + validation + YAML round-trip
Browse filesSprint S6 du plan rewrite ciblé. **Clôture la Phase 1** du
rewrite (squelette + règles d'architecture, S3-S6).
Pose le contrat d'un DAG de pipeline composée — purement
déclaratif, sérialisable en YAML, valide statiquement sans
instancier aucun module. C'est ce qui permettra à la BnF de
versionner ``ocr_llm_alto_remap.yaml`` en git et de le faire
exécuter par un service applicatif au S19.
Différence-clé avec l'ancien ``picarones.core.pipeline`` (Sprint 63)
-------------------------------------------------------------------
L'ancien ``PipelineStep`` portait un champ ``module: BaseModule``
— une instance d'objet exécutable. Conséquence : la spec
n'était PAS sérialisable en YAML (un objet Python au milieu d'une
dataclass), et un test qui voulait juste valider la cohérence des
types devait instancier des stubs.
Le nouveau ``PipelineStep`` ne porte qu'un ``adapter_name: str``.
Le mapping ``nom → instance`` est maintenu par un service
applicatif (``adapter_registry``) au S19 et résolu au moment de
l'exécution. Bénéfices :
- YAML versionnable en git indépendamment de l'environnement
Python (BnF peut commit ``my_pipeline.yaml`` sans imposer aux
contributeurs d'avoir tous les SDK installés).
- ``validate_spec`` s'exécute sans instancier aucun adapter →
tests rapides et déterministes.
- Le rapport peut citer le YAML exact, le commit du code et la
version des adapters — séparation propre de la déclaration et
de l'implémentation.
L'ancien code reste en place et continue à être utilisé par
``measurements.runner``. Sa migration en re-exports vers le
nouveau pipeline est S10/S11.
Modules livrés
--------------
``picarones/pipeline/spec.py``
- ``PipelineStep`` frozen pydantic (id, kind, adapter_name,
params, input_types, output_types, inputs_from). Validation
de l'id (alphanum + ``_-``, refus de ``__initial__``).
- ``PipelineSpec`` frozen pydantic (name, description,
initial_inputs, steps). Helper ``step_by_id``.
- Constante ``INITIAL_STEP_ID = "__initial__"`` pour les
références ``inputs_from`` vers les entrées du runner.
``picarones/pipeline/types.py``
- ``RunContext`` (document_id, code_version, pipeline_name,
workspace_uri). Contexte passé à chaque ``execute()``.
- ``StepResult`` (step_id, succeeded, duration_seconds,
produced_artifacts, error). Validation duration_seconds >= 0.
- ``PipelineResult`` (pipeline_name, document_id, step_results,
succeeded, duration_seconds, artifacts). Helpers
``step_result_by_id`` et ``artifacts_of_type``.
``picarones/pipeline/protocols.py``
- ``ExecutionMode = Literal["io", "cpu"]``.
- ``StepExecutor`` (Protocol runtime_checkable) :
name + input_types + output_types + execution_mode + execute.
Le runner utilisera ``isinstance(adapter, StepExecutor)``
pour valider les adapters au S11.
``picarones/pipeline/validation.py``
- ``ValidationError`` frozen pydantic (step_id, code, message).
7 codes : ``empty_pipeline``, ``duplicate_id``,
``unknown_adapter``, ``missing_input``, ``inputs_from_unused``,
``unknown_input_source``, ``source_does_not_produce_type``.
- ``validate_spec(spec, available_adapters=None)`` retourne la
liste des erreurs (ne s'arrête pas à la première).
``available_adapters=None`` saute la check d'adapters →
permet de valider un YAML sans avoir le runtime chargé.
``picarones/pipeline/yaml_io.py``
- ``dump_spec_to_yaml(spec)`` → str YAML déterministe (block
style, sort_keys=False, allow_unicode=True).
- ``load_spec_from_yaml(text)`` → ``PipelineSpec`` validée.
- YAML vide → ``PicaronesError`` explicite.
``picarones/pipeline/__init__.py`` expose 12 symboles publics.
Mise à jour de la whitelist de la couche
----------------------------------------
``tests/architecture/test_layer_dependencies.py`` :
``EXTERNAL_ALLOWED["pipeline"]`` ajoute ``"yaml"``. Justifié :
versionner les pipelines en git en YAML est un cas d'usage
explicite du rewrite (cf. docs/roadmap/rewrite-2026.md).
Anti-sur-ingénierie respectée
-----------------------------
- Pas de typage des ``params`` par adapter (chaque adapter
validera les siens au runtime).
- Pas de versioning de schéma de spec (rebump pydantic suffit).
- Pas d'``outputs_preferred`` ("preferred_text = step3.RAW_TEXT")
reporté quand un caller en aura concrètement besoin.
- Pas de détection de cycles graphes complexe — le DAG est
exprimé par ordre des steps, donc une boucle = référence vers
un nom inconnu, déjà détectée.
Tests — 42 nouveaux tests
-------------------------
- tests/pipeline/test_sprint_a14_s6_spec.py (12) — PipelineStep
validation (id space/dot/réservé), params, inputs_from, frozen,
extra=forbid. PipelineSpec minimal/avec steps/step_by_id.
- tests/pipeline/test_sprint_a14_s6_validation.py (14) — 4 cas
valides (dont **def of done : Tesseract+LLM+ALTO_remap**) ;
10 cas invalides (DAG vide, missing_input, ordre incorrect,
duplicate_id, unknown_adapter avec/sans registry,
inputs_from_unused, unknown_input_source,
source_does_not_produce_type, multi_errors).
- tests/pipeline/test_sprint_a14_s6_yaml_roundtrip.py (6) —
round-trip preserve l'égalité (paramétré sur 2 specs),
idempotence du dump → load → dump, YAML lisible (block style,
ordre des champs respecté), vide → erreur, type bogus → erreur.
- tests/pipeline/test_sprint_a14_s6_protocols.py (10) —
RunContext, StepResult success/failure/duration < 0 rejeté,
PipelineResult avec helpers, StepExecutor satisfait par stub +
classe non-conforme rejetée.
Critère go/no-go S6 atteint
---------------------------
La spec ``tesseract_llm_alto_remap`` (3 étapes : OCR → LLM
correction → ALTO remap, avec inputs_from explicites) :
- se construit sans erreur,
- valide à zéro erreur via ``validate_spec``,
- se dump en YAML lisible,
- se reload en spec strictement égale (testé).
État de la suite
----------------
``pytest tests/ -q`` → 4074 passed, 6 skipped, 2 failed.
+42 tests par rapport à S5. Les 2 fails restants sont
strictement environnementaux (sous-process pytest sans
``pip install -e .``). Aucune régression S6.
Critère fin de Phase 1 atteint : tests d'architecture passent,
les 4 cercles internes (domain, evaluation, pipeline) ont leurs
contrats déclaratifs et runtime + validation. L'outil actuel
reste utilisable pour la BnF. Prêt pour Phase 2 (S7-S12 :
PipelineExecutor + migration des calculs).
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- picarones/pipeline/__init__.py +49 -19
- picarones/pipeline/protocols.py +102 -0
- picarones/pipeline/spec.py +170 -0
- picarones/pipeline/types.py +143 -0
- picarones/pipeline/validation.py +218 -0
- picarones/pipeline/yaml_io.py +59 -0
- tests/architecture/test_layer_dependencies.py +5 -0
- tests/pipeline/__init__.py +0 -0
- tests/pipeline/test_sprint_a14_s6_protocols.py +157 -0
- tests/pipeline/test_sprint_a14_s6_spec.py +113 -0
- tests/pipeline/test_sprint_a14_s6_validation.py +308 -0
- tests/pipeline/test_sprint_a14_s6_yaml_roundtrip.py +128 -0
|
@@ -1,27 +1,33 @@
|
|
| 1 |
"""Cercle 2 — Pipeline execution.
|
| 2 |
|
| 3 |
Exécution séquentielle ou DAG-branchante d'une chaîne de modules
|
| 4 |
-
tiers (
|
| 5 |
-
l'utilisateur amène ses propres
|
| 6 |
-
reconstructeur ALTO ; le pipeline executor les compose,
|
| 7 |
-
types aux jonctions et évalue automatiquement chaque
|
| 8 |
-
produit contre la GT correspondante.
|
| 9 |
|
| 10 |
-
Modules
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
- ``executor.py`` — ``PipelineExecutor.run(spec, document, inputs
|
| 15 |
-
exécute mono-document avec capture gracieuse des erreurs.
|
| 16 |
-
- ``runner.py`` — ``CorpusRunner`` orchestre l'executor sur un
|
| 17 |
-
|
| 18 |
-
d'exécution réelle**
|
| 19 |
-
propre** (signal aux workers en cours).
|
| 20 |
- ``cache.py`` — ``ArtifactCache`` indexé par
|
| 21 |
-
``hash(content + spec + code_version)``
|
| 22 |
-
(Sprint S7).
|
| 23 |
-
- ``protocols.py`` — protocole ``StepExecutor`` que doivent
|
| 24 |
-
implémenter les adaptateurs.
|
| 25 |
|
| 26 |
Cible du Sprint S12 : équivalence numérique CER/WER avec l'ancien
|
| 27 |
``measurements.runner`` à 1e-9 près sur les fixtures.
|
|
@@ -29,4 +35,28 @@ Cible du Sprint S12 : équivalence numérique CER/WER avec l'ancien
|
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Cercle 2 — Pipeline execution.
|
| 2 |
|
| 3 |
Exécution séquentielle ou DAG-branchante d'une chaîne de modules
|
| 4 |
+
tiers (``StepExecutor``). Picarones ne fournit **aucun module
|
| 5 |
+
métier** — l'utilisateur amène ses propres adapters OCR/LLM/VLM/
|
| 6 |
+
correcteur/reconstructeur ALTO ; le pipeline executor les compose,
|
| 7 |
+
valide les types aux jonctions et évalue automatiquement chaque
|
| 8 |
+
artefact produit contre la GT correspondante.
|
| 9 |
|
| 10 |
+
Modules livrés au S6
|
| 11 |
+
--------------------
|
| 12 |
+
- ``spec.py`` — ``PipelineStep``, ``PipelineSpec``, ``INITIAL_STEP_ID``.
|
| 13 |
+
Spec déclarative sérialisable en YAML (cf. ``yaml_io.py``).
|
| 14 |
+
- ``types.py`` — ``RunContext``, ``StepResult``, ``PipelineResult``.
|
| 15 |
+
Types runtime de l'executor.
|
| 16 |
+
- ``protocols.py`` — ``StepExecutor`` (Protocol), ``ExecutionMode``.
|
| 17 |
+
Contrat d'un adapter exécutable.
|
| 18 |
+
- ``validation.py`` — ``validate_spec(spec, available_adapters)``,
|
| 19 |
+
``ValidationError``. Validation statique sans instancier de module.
|
| 20 |
+
- ``yaml_io.py`` — ``dump_spec_to_yaml`` / ``load_spec_from_yaml``.
|
| 21 |
|
| 22 |
+
À venir aux Sprints S7-S8
|
| 23 |
+
-------------------------
|
| 24 |
+
- ``executor.py`` — ``PipelineExecutor.run(spec, document, inputs,
|
| 25 |
+
context)`` exécute mono-document avec capture gracieuse des erreurs.
|
| 26 |
+
- ``runner.py`` — ``CorpusRunner`` orchestre l'executor sur un corpus
|
| 27 |
+
complet avec **backpressure**, **timeout depuis le début
|
| 28 |
+
d'exécution réelle**, **annulation propre**.
|
|
|
|
| 29 |
- ``cache.py`` — ``ArtifactCache`` indexé par
|
| 30 |
+
``hash(content + spec + code_version)``.
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
Cible du Sprint S12 : équivalence numérique CER/WER avec l'ancien
|
| 33 |
``measurements.runner`` à 1e-9 près sur les fixtures.
|
|
|
|
| 35 |
|
| 36 |
from __future__ import annotations
|
| 37 |
|
| 38 |
+
from picarones.pipeline.protocols import ExecutionMode, StepExecutor
|
| 39 |
+
from picarones.pipeline.spec import INITIAL_STEP_ID, PipelineSpec, PipelineStep
|
| 40 |
+
from picarones.pipeline.types import PipelineResult, RunContext, StepResult
|
| 41 |
+
from picarones.pipeline.validation import ValidationError, validate_spec
|
| 42 |
+
from picarones.pipeline.yaml_io import dump_spec_to_yaml, load_spec_from_yaml
|
| 43 |
+
|
| 44 |
+
__all__ = [
|
| 45 |
+
# Spec déclarative
|
| 46 |
+
"PipelineSpec",
|
| 47 |
+
"PipelineStep",
|
| 48 |
+
"INITIAL_STEP_ID",
|
| 49 |
+
# Runtime types
|
| 50 |
+
"RunContext",
|
| 51 |
+
"StepResult",
|
| 52 |
+
"PipelineResult",
|
| 53 |
+
# Protocol
|
| 54 |
+
"StepExecutor",
|
| 55 |
+
"ExecutionMode",
|
| 56 |
+
# Validation
|
| 57 |
+
"validate_spec",
|
| 58 |
+
"ValidationError",
|
| 59 |
+
# YAML IO
|
| 60 |
+
"dump_spec_to_yaml",
|
| 61 |
+
"load_spec_from_yaml",
|
| 62 |
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``StepExecutor`` (Protocol) — Sprint A14-S6.
|
| 2 |
+
|
| 3 |
+
Contrat que doit satisfaire tout adapter exécutable par le pipeline
|
| 4 |
+
runner. Une fonction ou une classe peut satisfaire le protocole —
|
| 5 |
+
le runner ne se soucie que de l'interface.
|
| 6 |
+
|
| 7 |
+
Implémentations concrètes au Sprint S11 dans ``picarones/adapters/``
|
| 8 |
+
(Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI, OpenAI,
|
| 9 |
+
Anthropic, Mistral, Ollama, ...).
|
| 10 |
+
|
| 11 |
+
Pattern d'utilisation cible :
|
| 12 |
+
|
| 13 |
+
.. code-block:: python
|
| 14 |
+
|
| 15 |
+
class TesseractExecutor:
|
| 16 |
+
name = "tesseract"
|
| 17 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 18 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 19 |
+
execution_mode = "cpu"
|
| 20 |
+
|
| 21 |
+
def execute(
|
| 22 |
+
self,
|
| 23 |
+
inputs: dict[ArtifactType, Artifact],
|
| 24 |
+
params: dict,
|
| 25 |
+
context: RunContext,
|
| 26 |
+
) -> dict[ArtifactType, Artifact]:
|
| 27 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 28 |
+
text = pytesseract.image_to_string(image_artifact.uri, **params)
|
| 29 |
+
return {ArtifactType.RAW_TEXT: build_text_artifact(text, context)}
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
from typing import Literal, Protocol, runtime_checkable
|
| 35 |
+
|
| 36 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 37 |
+
from picarones.pipeline.types import RunContext
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
#: Mode d'exécution déclaré par l'adapter. Le runner choisit
|
| 41 |
+
#: ``ProcessPoolExecutor`` pour ``"cpu"``, ``ThreadPoolExecutor`` pour
|
| 42 |
+
#: ``"io"``.
|
| 43 |
+
ExecutionMode = Literal["io", "cpu"]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@runtime_checkable
|
| 47 |
+
class StepExecutor(Protocol):
|
| 48 |
+
"""Contrat d'un adapter exécutable.
|
| 49 |
+
|
| 50 |
+
Trois propriétés statiques (le runner les inspecte sans appeler
|
| 51 |
+
``execute()``) :
|
| 52 |
+
|
| 53 |
+
- ``name`` : identifiant stable (cf. ``PipelineStep.adapter_name``).
|
| 54 |
+
- ``input_types`` : types consommés.
|
| 55 |
+
- ``output_types`` : types produits.
|
| 56 |
+
- ``execution_mode`` : ``"io"`` ou ``"cpu"``.
|
| 57 |
+
|
| 58 |
+
Une méthode ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``.
|
| 59 |
+
|
| 60 |
+
Le runner garantit que :
|
| 61 |
+
|
| 62 |
+
- ``inputs`` contient au moins tous les types listés dans
|
| 63 |
+
``input_types``.
|
| 64 |
+
- ``params`` est le dict ``PipelineStep.params`` (copie).
|
| 65 |
+
- ``context`` est le ``RunContext`` du document courant.
|
| 66 |
+
|
| 67 |
+
L'adapter garantit que :
|
| 68 |
+
|
| 69 |
+
- Le dict retourné contient au moins tous les types listés dans
|
| 70 |
+
``output_types``. Le runner valide cette propriété et marque
|
| 71 |
+
le step en échec si un type promis manque.
|
| 72 |
+
- Toute exception levée est propagée au runner ; ne rien capturer
|
| 73 |
+
silencieusement.
|
| 74 |
+
|
| 75 |
+
Le ``execute`` reste **pur du point de vue du runner** : il
|
| 76 |
+
peut faire des side effects (écrire un fichier, appeler une API),
|
| 77 |
+
mais le runner garantit qu'il ne sera pas appelé deux fois pour
|
| 78 |
+
le même couple ``(document_id, step_id)`` dans le même run
|
| 79 |
+
(cache du Sprint S7).
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def name(self) -> str: ...
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def input_types(self) -> frozenset[ArtifactType]: ...
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def output_types(self) -> frozenset[ArtifactType]: ...
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def execution_mode(self) -> ExecutionMode: ...
|
| 93 |
+
|
| 94 |
+
def execute(
|
| 95 |
+
self,
|
| 96 |
+
inputs: dict[ArtifactType, Artifact],
|
| 97 |
+
params: dict[str, str | int | float | bool],
|
| 98 |
+
context: RunContext,
|
| 99 |
+
) -> dict[ArtifactType, Artifact]: ...
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
__all__ = ["StepExecutor", "ExecutionMode"]
|
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PipelineStep`` et ``PipelineSpec`` — Sprint A14-S6.
|
| 2 |
+
|
| 3 |
+
Description **purement déclarative** d'un DAG de transformation
|
| 4 |
+
documentaire. Sérialisable en YAML, versionnable en git, valide
|
| 5 |
+
sans avoir besoin d'instancier les modules concrets.
|
| 6 |
+
|
| 7 |
+
Différence avec l'ancien ``picarones.core.pipeline`` (Sprint 63)
|
| 8 |
+
----------------------------------------------------------------
|
| 9 |
+
L'ancien ``PipelineStep`` portait un champ ``module: BaseModule``
|
| 10 |
+
— une **instance** d'objet exécutable. Conséquence : la spec
|
| 11 |
+
n'était pas sérialisable en YAML, et un test qui voulait juste
|
| 12 |
+
valider la cohérence des types devait instancier des stubs.
|
| 13 |
+
|
| 14 |
+
Ici, ``PipelineStep`` ne porte qu'un ``adapter_name: str``. Le
|
| 15 |
+
mapping ``nom → instance`` est maintenu par un service applicatif
|
| 16 |
+
(``picarones.app.services.adapter_registry`` au S19) et résolu au
|
| 17 |
+
moment de l'exécution, pas de la spec.
|
| 18 |
+
|
| 19 |
+
Bénéfices :
|
| 20 |
+
|
| 21 |
+
- Le YAML d'une pipeline composée est versionnable en git
|
| 22 |
+
indépendamment de l'environnement Python (BnF peut commit
|
| 23 |
+
``ocr_llm_alto_remap.yaml`` sans imposer aux contributeurs
|
| 24 |
+
d'avoir tous les SDK installés).
|
| 25 |
+
- ``validate_spec`` peut s'exécuter sans instancier aucun module
|
| 26 |
+
→ tests rapides et déterministes.
|
| 27 |
+
- Le rapport de reproductibilité peut citer le YAML exact, le
|
| 28 |
+
commit du code et la version des adapters utilisés —
|
| 29 |
+
séparation propre de la déclaration et de l'implémentation.
|
| 30 |
+
|
| 31 |
+
Anti-sur-ingénierie
|
| 32 |
+
-------------------
|
| 33 |
+
- Pas de typage des ``params`` par adapter ici (chaque adapter
|
| 34 |
+
validera ses propres params au moment de l'exécution).
|
| 35 |
+
- Pas de versioning de spec — un nouveau champ se traduit par un
|
| 36 |
+
rebump pydantic. Si on veut migrer entre versions de schéma,
|
| 37 |
+
on l'ajoutera quand le besoin sera concret.
|
| 38 |
+
- Pas d'``outputs_preferred`` (mapping logique "preferred_text =
|
| 39 |
+
step3.RAW_TEXT"). Reporté quand un caller en aura concrètement
|
| 40 |
+
besoin.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
from __future__ import annotations
|
| 44 |
+
|
| 45 |
+
import re
|
| 46 |
+
|
| 47 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 48 |
+
|
| 49 |
+
from picarones.domain.artifacts import ArtifactType
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
#: Identifiant d'étape — alphanum + ``_-``. Doit être un nom court
|
| 53 |
+
#: lisible par un humain dans les logs et le rapport.
|
| 54 |
+
_STEP_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$")
|
| 55 |
+
|
| 56 |
+
#: Sentinel pour ``inputs_from`` qui désigne les artefacts initiaux
|
| 57 |
+
#: fournis au runner (typiquement ``IMAGE``).
|
| 58 |
+
INITIAL_STEP_ID = "__initial__"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class PipelineStep(BaseModel):
|
| 62 |
+
"""Une étape déclarative dans un DAG de pipeline.
|
| 63 |
+
|
| 64 |
+
Attributs
|
| 65 |
+
---------
|
| 66 |
+
id:
|
| 67 |
+
Identifiant unique de l'étape dans la pipeline (alphanum +
|
| 68 |
+
``_-``). Sert dans les logs, le rapport, et comme cible
|
| 69 |
+
des références ``inputs_from`` des étapes en aval.
|
| 70 |
+
kind:
|
| 71 |
+
Catégorie informationnelle de l'étape (``"ocr"``,
|
| 72 |
+
``"post_correction"``, ``"alto_remapping"``,
|
| 73 |
+
``"alto_reconstruction"``, etc.). Pas de validation
|
| 74 |
+
d'enum — c'est un label libre que les services et le
|
| 75 |
+
rapport peuvent grouper. Par convention, en
|
| 76 |
+
``snake_case``.
|
| 77 |
+
adapter_name:
|
| 78 |
+
Nom de l'adapter dans le registre runtime (résolu par
|
| 79 |
+
``app/services`` au S19). Convention :
|
| 80 |
+
``"<provider>:<engine_or_model>"`` (ex : ``"tesseract"``,
|
| 81 |
+
``"openai:gpt-4o"``, ``"mistral:large"``,
|
| 82 |
+
``"<vendor>:<custom_module>"``).
|
| 83 |
+
params:
|
| 84 |
+
Paramètres passés à l'adapter au moment de l'exécution.
|
| 85 |
+
Format libre (chaque adapter valide les siens) — typage
|
| 86 |
+
scalaire pour rester sérialisable en YAML.
|
| 87 |
+
input_types:
|
| 88 |
+
Types d'artefacts consommés par l'étape. Validés par
|
| 89 |
+
``validate_spec`` contre les outputs des étapes antérieures.
|
| 90 |
+
output_types:
|
| 91 |
+
Types d'artefacts produits. Validés au runtime par
|
| 92 |
+
l'executor (qui vérifie que tous les types déclarés sont
|
| 93 |
+
bien dans le dict retourné par l'adapter).
|
| 94 |
+
inputs_from:
|
| 95 |
+
DAG branchant (héritage du Sprint 66). Pour chaque type
|
| 96 |
+
d'entrée, désigne explicitement l'étape source. La chaîne
|
| 97 |
+
spéciale ``"__initial__"`` désigne les entrées initiales
|
| 98 |
+
du runner. Si le dict est vide, l'executor prend la
|
| 99 |
+
version la plus récente de chaque type dans le bag.
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 103 |
+
|
| 104 |
+
id: str = Field(min_length=1, max_length=128)
|
| 105 |
+
kind: str = Field(min_length=1, max_length=64)
|
| 106 |
+
adapter_name: str = Field(min_length=1, max_length=256)
|
| 107 |
+
params: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
| 108 |
+
input_types: tuple[ArtifactType, ...] = Field(default_factory=tuple)
|
| 109 |
+
output_types: tuple[ArtifactType, ...] = Field(default_factory=tuple)
|
| 110 |
+
inputs_from: dict[ArtifactType, str] = Field(default_factory=dict)
|
| 111 |
+
|
| 112 |
+
@field_validator("id")
|
| 113 |
+
@classmethod
|
| 114 |
+
def _validate_step_id(cls, v: str) -> str:
|
| 115 |
+
if not _STEP_ID_RE.match(v):
|
| 116 |
+
from picarones.domain.errors import PicaronesError
|
| 117 |
+
raise PicaronesError(
|
| 118 |
+
f"step id invalide : {v!r}. "
|
| 119 |
+
f"Doit matcher {_STEP_ID_RE.pattern!r} (alphanum + _-)."
|
| 120 |
+
)
|
| 121 |
+
if v == INITIAL_STEP_ID:
|
| 122 |
+
from picarones.domain.errors import PicaronesError
|
| 123 |
+
raise PicaronesError(
|
| 124 |
+
f"step id réservé : {INITIAL_STEP_ID!r} désigne "
|
| 125 |
+
"les entrées initiales du runner."
|
| 126 |
+
)
|
| 127 |
+
return v
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class PipelineSpec(BaseModel):
|
| 131 |
+
"""DAG déclaratif d'une pipeline composée.
|
| 132 |
+
|
| 133 |
+
Sérialisable en YAML via ``model_dump()`` + ``yaml.safe_dump``,
|
| 134 |
+
chargeable via ``model_validate(yaml.safe_load(text))``. Le
|
| 135 |
+
round-trip est testé.
|
| 136 |
+
|
| 137 |
+
Attributs
|
| 138 |
+
---------
|
| 139 |
+
name:
|
| 140 |
+
Nom court de la pipeline (utilisé dans les logs, le cache,
|
| 141 |
+
le rapport). Convention ``snake_case``.
|
| 142 |
+
description:
|
| 143 |
+
Phrase courte d'introduction affichée dans le rapport.
|
| 144 |
+
initial_inputs:
|
| 145 |
+
Types d'artefacts qui doivent être fournis par le caller
|
| 146 |
+
au moment de l'exécution. Convention : ``(IMAGE,)`` pour
|
| 147 |
+
une pipeline OCR classique, ``(IMAGE, RAW_TEXT)`` pour
|
| 148 |
+
une post-correction qui part d'un OCR pré-calculé.
|
| 149 |
+
steps:
|
| 150 |
+
Étapes du DAG, ordonnées par dépendance topologique
|
| 151 |
+
d'exécution. Si une étape ``s2`` dépend de ``s1``, alors
|
| 152 |
+
``s1`` apparaît avant ``s2``. ``validate_spec`` détecte
|
| 153 |
+
les violations.
|
| 154 |
+
"""
|
| 155 |
+
|
| 156 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 157 |
+
|
| 158 |
+
name: str = Field(min_length=1, max_length=128)
|
| 159 |
+
description: str = ""
|
| 160 |
+
initial_inputs: tuple[ArtifactType, ...] = Field(default_factory=tuple)
|
| 161 |
+
steps: tuple[PipelineStep, ...] = Field(default_factory=tuple)
|
| 162 |
+
|
| 163 |
+
def step_by_id(self, step_id: str) -> PipelineStep | None:
|
| 164 |
+
for s in self.steps:
|
| 165 |
+
if s.id == step_id:
|
| 166 |
+
return s
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
__all__ = ["PipelineStep", "PipelineSpec", "INITIAL_STEP_ID"]
|
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``RunContext``, ``StepResult``, ``PipelineResult`` — Sprint A14-S6.
|
| 2 |
+
|
| 3 |
+
Types runtime du pipeline executor (à implémenter au Sprint S7).
|
| 4 |
+
Distincts des specs déclaratives (``picarones.pipeline.spec``) —
|
| 5 |
+
ces types portent les **résultats** de l'exécution, pas la
|
| 6 |
+
description du DAG.
|
| 7 |
+
|
| 8 |
+
Aucune logique métier ici : juste des dataclasses pydantic qu'un
|
| 9 |
+
service applicatif peut sérialiser dans le manifest d'un run.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 17 |
+
|
| 18 |
+
from picarones.domain.artifacts import Artifact
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class RunContext(BaseModel):
|
| 22 |
+
"""Contexte d'exécution passé à chaque ``StepExecutor.execute()``.
|
| 23 |
+
|
| 24 |
+
Le caller (typiquement ``app/services/benchmark_service`` au
|
| 25 |
+
S19) construit un ``RunContext`` par document et le passe à
|
| 26 |
+
l'executor pour chaque étape.
|
| 27 |
+
|
| 28 |
+
Attributs
|
| 29 |
+
---------
|
| 30 |
+
document_id:
|
| 31 |
+
``DocumentRef.id`` du document en cours de traitement.
|
| 32 |
+
code_version:
|
| 33 |
+
Version du code (``picarones.__version__``) au moment du
|
| 34 |
+
run. Sert à étiqueter la ``ProvenanceRecord`` de chaque
|
| 35 |
+
artefact produit.
|
| 36 |
+
pipeline_name:
|
| 37 |
+
Nom de la pipeline en cours. Permet à un adapter de
|
| 38 |
+
loguer ``[pipeline_x] step_y : ...`` plutôt que
|
| 39 |
+
``[unknown] ...``.
|
| 40 |
+
workspace_uri:
|
| 41 |
+
URI/chemin du workspace dans lequel l'adapter peut écrire
|
| 42 |
+
ses artefacts intermédiaires. ``None`` autorisé pour les
|
| 43 |
+
adapters qui n'écrivent rien sur disque (mode in-memory).
|
| 44 |
+
|
| 45 |
+
Anti-sur-ingénierie : pas de logger injecté, pas d'horloge
|
| 46 |
+
abstraite, pas de cancellation token. Ces extras viendront
|
| 47 |
+
quand un caller en aura concrètement besoin (probablement S7
|
| 48 |
+
pour la cancellation, S8 pour le timeout réel).
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 52 |
+
|
| 53 |
+
document_id: str = Field(min_length=1, max_length=256)
|
| 54 |
+
code_version: str = Field(min_length=1, max_length=128)
|
| 55 |
+
pipeline_name: str = Field(min_length=1, max_length=128)
|
| 56 |
+
workspace_uri: str | None = Field(default=None, max_length=2048)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class StepResult(BaseModel):
|
| 60 |
+
"""Résultat de l'exécution d'une étape sur un document.
|
| 61 |
+
|
| 62 |
+
Sérialisable JSON pour persistance dans le manifest du run.
|
| 63 |
+
|
| 64 |
+
Attributs
|
| 65 |
+
---------
|
| 66 |
+
step_id:
|
| 67 |
+
Identifiant de l'étape (cf. ``PipelineStep.id``).
|
| 68 |
+
succeeded:
|
| 69 |
+
``True`` si l'étape s'est exécutée sans lever d'exception
|
| 70 |
+
et a produit tous les types déclarés dans
|
| 71 |
+
``output_types``. ``False`` sinon.
|
| 72 |
+
duration_seconds:
|
| 73 |
+
Wall-clock time de ``execute()`` (du début effectif à la
|
| 74 |
+
fin). L'executor du S8 garantira que ce temps est mesuré
|
| 75 |
+
depuis le démarrage réel (pas depuis la submission au pool).
|
| 76 |
+
produced_artifacts:
|
| 77 |
+
Map ``{ArtifactType: artifact_id}`` des artefacts produits.
|
| 78 |
+
Vide en cas d'échec.
|
| 79 |
+
error:
|
| 80 |
+
``None`` en cas de succès ; sinon message d'erreur. Format
|
| 81 |
+
libre (le caller décide de la structure dans son rapport).
|
| 82 |
+
"""
|
| 83 |
+
|
| 84 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 85 |
+
|
| 86 |
+
step_id: str = Field(min_length=1, max_length=128)
|
| 87 |
+
succeeded: bool
|
| 88 |
+
duration_seconds: float = Field(ge=0.0)
|
| 89 |
+
produced_artifacts: dict[str, str] = Field(default_factory=dict)
|
| 90 |
+
"""Map ``{ArtifactType.value: Artifact.id}``.
|
| 91 |
+
|
| 92 |
+
Sérialisée avec la valeur string de l'enum (``"raw_text"``,
|
| 93 |
+
``"alto_xml"``) pour faciliter la lecture humaine du JSON.
|
| 94 |
+
"""
|
| 95 |
+
error: str | None = None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class PipelineResult(BaseModel):
|
| 99 |
+
"""Résultat complet d'une exécution de pipeline sur un document.
|
| 100 |
+
|
| 101 |
+
Attributs
|
| 102 |
+
---------
|
| 103 |
+
pipeline_name:
|
| 104 |
+
Nom de la pipeline qui a produit ce résultat.
|
| 105 |
+
document_id:
|
| 106 |
+
Document traité.
|
| 107 |
+
step_results:
|
| 108 |
+
Résultats de chaque étape, dans l'ordre d'exécution.
|
| 109 |
+
succeeded:
|
| 110 |
+
``True`` ssi tous les ``step_results`` sont des succès.
|
| 111 |
+
Si ``False``, un ou plusieurs ``StepResult.error`` sont
|
| 112 |
+
non-None.
|
| 113 |
+
duration_seconds:
|
| 114 |
+
Wall-clock total (somme des étapes + overhead orchestration).
|
| 115 |
+
artifacts:
|
| 116 |
+
Liste **plate** de tous les artefacts produits par la
|
| 117 |
+
pipeline. Permet à un consommateur (rapport, vue
|
| 118 |
+
d'évaluation) d'accéder directement à un artefact par son
|
| 119 |
+
id sans parcourir l'arborescence des étapes.
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 123 |
+
|
| 124 |
+
pipeline_name: str
|
| 125 |
+
document_id: str
|
| 126 |
+
step_results: tuple[StepResult, ...] = Field(default_factory=tuple)
|
| 127 |
+
succeeded: bool = False
|
| 128 |
+
duration_seconds: float = Field(default=0.0, ge=0.0)
|
| 129 |
+
artifacts: tuple[Artifact, ...] = Field(default_factory=tuple)
|
| 130 |
+
|
| 131 |
+
def step_result_by_id(self, step_id: str) -> StepResult | None:
|
| 132 |
+
for r in self.step_results:
|
| 133 |
+
if r.step_id == step_id:
|
| 134 |
+
return r
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
def artifacts_of_type(self, artifact_type: Any) -> tuple[Artifact, ...]:
|
| 138 |
+
"""Retourne tous les artefacts du type donné dans l'ordre
|
| 139 |
+
de production."""
|
| 140 |
+
return tuple(a for a in self.artifacts if a.type == artifact_type)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
__all__ = ["RunContext", "StepResult", "PipelineResult"]
|
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``validate_spec`` — Sprint A14-S6.
|
| 2 |
+
|
| 3 |
+
Validation statique d'une ``PipelineSpec`` : vérifier que les
|
| 4 |
+
types s'enchaînent, qu'il n'y a pas d'IDs dupliqués, que les
|
| 5 |
+
références ``inputs_from`` pointent bien vers des étapes
|
| 6 |
+
antérieures qui produisent le bon type, et (optionnellement) que
|
| 7 |
+
les ``adapter_name`` existent dans un registre fourni.
|
| 8 |
+
|
| 9 |
+
S'exécute **sans instancier aucun adapter** — c'est le bénéfice
|
| 10 |
+
clé de la séparation déclaratif/runtime du S6.
|
| 11 |
+
|
| 12 |
+
API :
|
| 13 |
+
|
| 14 |
+
>>> errors = validate_spec(spec)
|
| 15 |
+
>>> if errors:
|
| 16 |
+
... for e in errors:
|
| 17 |
+
... print(f"{e.step_id}: {e.message}")
|
| 18 |
+
|
| 19 |
+
Le caller décide de la suite — typiquement un service applicatif
|
| 20 |
+
refuse de démarrer un run si la spec a des erreurs.
|
| 21 |
+
|
| 22 |
+
Anti-sur-ingénierie
|
| 23 |
+
-------------------
|
| 24 |
+
Pas de détection de cycles graphes complexe (le DAG est exprimé
|
| 25 |
+
par ordre des steps, donc impossible de référencer une étape
|
| 26 |
+
postérieure : si tu as une boucle, c'est qu'une référence pointe
|
| 27 |
+
vers un nom inconnu, déjà détecté).
|
| 28 |
+
|
| 29 |
+
Pas de validation des params (chaque adapter validera les siens
|
| 30 |
+
au moment de l'exécution — le format libre des params est un
|
| 31 |
+
choix assumé).
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
from pydantic import BaseModel, ConfigDict
|
| 37 |
+
|
| 38 |
+
from picarones.domain.artifacts import ArtifactType
|
| 39 |
+
from picarones.pipeline.spec import INITIAL_STEP_ID, PipelineSpec, PipelineStep
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ValidationError(BaseModel):
|
| 43 |
+
"""Une erreur de validation d'une ``PipelineSpec``.
|
| 44 |
+
|
| 45 |
+
Format structuré pour faciliter le rendu (CLI, rapport, JSON).
|
| 46 |
+
Volontairement plat — pas de hiérarchie d'erreurs ; on ajoute
|
| 47 |
+
un ``code`` discriminant si un caller en a besoin.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 51 |
+
|
| 52 |
+
step_id: str | None
|
| 53 |
+
"""Step concerné, ou ``None`` pour les erreurs globales (DAG vide,
|
| 54 |
+
ID dupliqué détecté entre deux steps...)."""
|
| 55 |
+
|
| 56 |
+
code: str
|
| 57 |
+
"""Identifiant court (``"duplicate_id"``, ``"unknown_adapter"``,
|
| 58 |
+
``"missing_input"``, ``"unknown_input_source"``, ...). Permet
|
| 59 |
+
à un test d'asserter sur le code plutôt que sur le message
|
| 60 |
+
français.
|
| 61 |
+
"""
|
| 62 |
+
|
| 63 |
+
message: str
|
| 64 |
+
"""Description humainement lisible (français)."""
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def validate_spec(
|
| 68 |
+
spec: PipelineSpec,
|
| 69 |
+
available_adapters: set[str] | None = None,
|
| 70 |
+
) -> list[ValidationError]:
|
| 71 |
+
"""Vérifie une ``PipelineSpec`` et retourne la liste des erreurs.
|
| 72 |
+
|
| 73 |
+
Parameters
|
| 74 |
+
----------
|
| 75 |
+
spec:
|
| 76 |
+
La spec à valider.
|
| 77 |
+
available_adapters:
|
| 78 |
+
Set des noms d'adapters connus. Si fourni, chaque
|
| 79 |
+
``adapter_name`` du DAG est vérifié. Si ``None`` (défaut),
|
| 80 |
+
cette validation est sautée — utile pour les tests qui
|
| 81 |
+
valident la cohérence d'un YAML sans avoir le runtime
|
| 82 |
+
chargé.
|
| 83 |
+
|
| 84 |
+
Returns
|
| 85 |
+
-------
|
| 86 |
+
list[ValidationError]
|
| 87 |
+
Liste vide si la spec est valide ; sinon un ou plusieurs
|
| 88 |
+
problèmes (ne s'arrête pas à la première erreur — le
|
| 89 |
+
caller veut tout voir d'un coup).
|
| 90 |
+
"""
|
| 91 |
+
errors: list[ValidationError] = []
|
| 92 |
+
|
| 93 |
+
# -- 0. Steps absents
|
| 94 |
+
if not spec.steps:
|
| 95 |
+
errors.append(ValidationError(
|
| 96 |
+
step_id=None,
|
| 97 |
+
code="empty_pipeline",
|
| 98 |
+
message="pipeline vide : au moins une étape est requise",
|
| 99 |
+
))
|
| 100 |
+
return errors # impossible de continuer
|
| 101 |
+
|
| 102 |
+
# -- 1. IDs dupliqués
|
| 103 |
+
seen_ids: dict[str, int] = {}
|
| 104 |
+
for i, step in enumerate(spec.steps):
|
| 105 |
+
if step.id in seen_ids:
|
| 106 |
+
errors.append(ValidationError(
|
| 107 |
+
step_id=step.id,
|
| 108 |
+
code="duplicate_id",
|
| 109 |
+
message=(
|
| 110 |
+
f"id dupliqué : '{step.id}' apparaît à l'étape {i} "
|
| 111 |
+
f"et précédemment à {seen_ids[step.id]}"
|
| 112 |
+
),
|
| 113 |
+
))
|
| 114 |
+
else:
|
| 115 |
+
seen_ids[step.id] = i
|
| 116 |
+
|
| 117 |
+
# -- 2. Adapter inconnu (si registre fourni)
|
| 118 |
+
if available_adapters is not None:
|
| 119 |
+
for step in spec.steps:
|
| 120 |
+
if step.adapter_name not in available_adapters:
|
| 121 |
+
errors.append(ValidationError(
|
| 122 |
+
step_id=step.id,
|
| 123 |
+
code="unknown_adapter",
|
| 124 |
+
message=(
|
| 125 |
+
f"adapter '{step.adapter_name}' non disponible. "
|
| 126 |
+
f"Adapters connus : {sorted(available_adapters)}"
|
| 127 |
+
),
|
| 128 |
+
))
|
| 129 |
+
|
| 130 |
+
# -- 3. Cohérence des types et des références inputs_from
|
| 131 |
+
# On simule un parcours topologique en ordre de spec.steps.
|
| 132 |
+
# À chaque étape :
|
| 133 |
+
# a) Tout type de input_types doit être disponible (soit
|
| 134 |
+
# initial, soit produit par une étape antérieure).
|
| 135 |
+
# b) Si inputs_from[type] = "src", "src" doit être une étape
|
| 136 |
+
# antérieure connue (ou "__initial__") qui produit ce type.
|
| 137 |
+
|
| 138 |
+
# Map { step_id (ou "__initial__") -> set(types qu'elle produit) }.
|
| 139 |
+
step_outputs: dict[str, set[ArtifactType]] = {
|
| 140 |
+
INITIAL_STEP_ID: set(spec.initial_inputs),
|
| 141 |
+
}
|
| 142 |
+
# Set des types disponibles à un instant t (latest seulement).
|
| 143 |
+
available: set[ArtifactType] = set(spec.initial_inputs)
|
| 144 |
+
|
| 145 |
+
for step in spec.steps:
|
| 146 |
+
errors.extend(_validate_step_against_state(
|
| 147 |
+
step=step,
|
| 148 |
+
step_outputs=step_outputs,
|
| 149 |
+
available=available,
|
| 150 |
+
))
|
| 151 |
+
# Mise à jour de l'état pour les étapes suivantes.
|
| 152 |
+
step_outputs[step.id] = set(step.output_types)
|
| 153 |
+
available.update(step.output_types)
|
| 154 |
+
|
| 155 |
+
return errors
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _validate_step_against_state(
|
| 159 |
+
*,
|
| 160 |
+
step: PipelineStep,
|
| 161 |
+
step_outputs: dict[str, set[ArtifactType]],
|
| 162 |
+
available: set[ArtifactType],
|
| 163 |
+
) -> list[ValidationError]:
|
| 164 |
+
"""Valide une étape donnée contre l'état des types
|
| 165 |
+
disponibles et des outputs des étapes antérieures."""
|
| 166 |
+
errors: list[ValidationError] = []
|
| 167 |
+
|
| 168 |
+
# 3.a — entrées disponibles
|
| 169 |
+
missing = [t for t in step.input_types if t not in available]
|
| 170 |
+
if missing:
|
| 171 |
+
errors.append(ValidationError(
|
| 172 |
+
step_id=step.id,
|
| 173 |
+
code="missing_input",
|
| 174 |
+
message=(
|
| 175 |
+
f"types d'entrée non disponibles : "
|
| 176 |
+
f"{[t.value for t in missing]}. "
|
| 177 |
+
f"Disponibles : {sorted(t.value for t in available)}"
|
| 178 |
+
),
|
| 179 |
+
))
|
| 180 |
+
|
| 181 |
+
# 3.b — références inputs_from
|
| 182 |
+
for ref_type, ref_step in step.inputs_from.items():
|
| 183 |
+
if ref_type not in step.input_types:
|
| 184 |
+
errors.append(ValidationError(
|
| 185 |
+
step_id=step.id,
|
| 186 |
+
code="inputs_from_unused",
|
| 187 |
+
message=(
|
| 188 |
+
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 189 |
+
"mais l'étape ne consomme pas ce type "
|
| 190 |
+
f"(input_types = {[t.value for t in step.input_types]})"
|
| 191 |
+
),
|
| 192 |
+
))
|
| 193 |
+
continue
|
| 194 |
+
if ref_step not in step_outputs:
|
| 195 |
+
errors.append(ValidationError(
|
| 196 |
+
step_id=step.id,
|
| 197 |
+
code="unknown_input_source",
|
| 198 |
+
message=(
|
| 199 |
+
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 200 |
+
"ne désigne pas une étape antérieure connue "
|
| 201 |
+
f"({INITIAL_STEP_ID!r} pour les entrées initiales)"
|
| 202 |
+
),
|
| 203 |
+
))
|
| 204 |
+
continue
|
| 205 |
+
if ref_type not in step_outputs[ref_step]:
|
| 206 |
+
errors.append(ValidationError(
|
| 207 |
+
step_id=step.id,
|
| 208 |
+
code="source_does_not_produce_type",
|
| 209 |
+
message=(
|
| 210 |
+
f"inputs_from[{ref_type.value}]={ref_step!r} "
|
| 211 |
+
f"mais '{ref_step}' ne produit pas {ref_type.value!r}"
|
| 212 |
+
),
|
| 213 |
+
))
|
| 214 |
+
|
| 215 |
+
return errors
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
__all__ = ["validate_spec", "ValidationError"]
|
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sérialisation YAML des ``PipelineSpec`` — Sprint A14-S6.
|
| 2 |
+
|
| 3 |
+
Helpers de chargement / écriture YAML. Volontairement minces —
|
| 4 |
+
``pydantic.model_dump()`` produit déjà un dict imbriqué
|
| 5 |
+
sérialisable, et ``yaml.safe_dump`` / ``yaml.safe_load`` sont
|
| 6 |
+
suffisants pour le contrat round-trip.
|
| 7 |
+
|
| 8 |
+
Pourquoi un module dédié plutôt qu'une méthode de classe ?
|
| 9 |
+
----------------------------------------------------------
|
| 10 |
+
Le ``domain/`` ne doit pas dépendre de PyYAML — c'est une lib
|
| 11 |
+
externe que la couche layer permet seulement à ``formats/``,
|
| 12 |
+
``app/`` et adjacents. ``pipeline/`` peut importer pyyaml
|
| 13 |
+
(autorisé par les règles du S3), donc le helper vit ici.
|
| 14 |
+
|
| 15 |
+
API :
|
| 16 |
+
|
| 17 |
+
>>> from picarones.pipeline import dump_spec_to_yaml, load_spec_from_yaml
|
| 18 |
+
>>> text = dump_spec_to_yaml(spec)
|
| 19 |
+
>>> spec2 = load_spec_from_yaml(text)
|
| 20 |
+
>>> spec == spec2
|
| 21 |
+
True
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import yaml
|
| 27 |
+
|
| 28 |
+
from picarones.pipeline.spec import PipelineSpec
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def dump_spec_to_yaml(spec: PipelineSpec) -> str:
|
| 32 |
+
"""Sérialise une ``PipelineSpec`` en YAML déterministe.
|
| 33 |
+
|
| 34 |
+
Le YAML produit est compatible avec ``load_spec_from_yaml``
|
| 35 |
+
et conserve l'ordre des champs et des étapes.
|
| 36 |
+
"""
|
| 37 |
+
payload = spec.model_dump(mode="json")
|
| 38 |
+
return yaml.safe_dump(
|
| 39 |
+
payload,
|
| 40 |
+
sort_keys=False, # conserve l'ordre des champs
|
| 41 |
+
allow_unicode=True, # préserve accents et caractères spéciaux
|
| 42 |
+
default_flow_style=False, # style "block" lisible
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_spec_from_yaml(text: str) -> PipelineSpec:
|
| 47 |
+
"""Parse une chaîne YAML et retourne une ``PipelineSpec`` validée.
|
| 48 |
+
|
| 49 |
+
Lève ``pydantic.ValidationError`` si le YAML ne respecte pas
|
| 50 |
+
le schéma, ou ``yaml.YAMLError`` si le YAML est mal formé.
|
| 51 |
+
"""
|
| 52 |
+
payload = yaml.safe_load(text)
|
| 53 |
+
if payload is None:
|
| 54 |
+
from picarones.domain.errors import PicaronesError
|
| 55 |
+
raise PicaronesError("YAML vide — pas de PipelineSpec à charger")
|
| 56 |
+
return PipelineSpec.model_validate(payload)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
__all__ = ["dump_spec_to_yaml", "load_spec_from_yaml"]
|
|
@@ -90,6 +90,11 @@ EXTERNAL_ALLOWED: dict[str, frozenset[str]] = {
|
|
| 90 |
"pipeline": frozenset({
|
| 91 |
"pydantic", "typing_extensions", "annotated_types",
|
| 92 |
"numpy", "scipy",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}),
|
| 94 |
"formats": frozenset({
|
| 95 |
"pydantic", "typing_extensions", "annotated_types",
|
|
|
|
| 90 |
"pipeline": frozenset({
|
| 91 |
"pydantic", "typing_extensions", "annotated_types",
|
| 92 |
"numpy", "scipy",
|
| 93 |
+
# S6 — yaml pour la sérialisation YAML des PipelineSpec
|
| 94 |
+
# (cf. picarones/pipeline/yaml_io.py). Versionner les
|
| 95 |
+
# pipelines en git en YAML est un cas d'usage explicite du
|
| 96 |
+
# rewrite, justifie l'ajout à la whitelist.
|
| 97 |
+
"yaml",
|
| 98 |
}),
|
| 99 |
"formats": frozenset({
|
| 100 |
"pydantic", "typing_extensions", "annotated_types",
|
|
File without changes
|
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S6 — protocoles ``StepExecutor`` + types runtime.
|
| 2 |
+
|
| 3 |
+
Vérifie que :
|
| 4 |
+
|
| 5 |
+
- une classe minimale satisfait ``StepExecutor`` ;
|
| 6 |
+
- ``RunContext``, ``StepResult``, ``PipelineResult`` se construisent
|
| 7 |
+
et sérialisent ;
|
| 8 |
+
- ``isinstance(x, StepExecutor)`` rejette les classes non-conformes.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
from picarones.domain import Artifact, ArtifactType
|
| 16 |
+
from picarones.pipeline import (
|
| 17 |
+
PipelineResult,
|
| 18 |
+
RunContext,
|
| 19 |
+
StepExecutor,
|
| 20 |
+
StepResult,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 25 |
+
# RunContext
|
| 26 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TestRunContext:
|
| 30 |
+
def test_minimal_context(self) -> None:
|
| 31 |
+
ctx = RunContext(
|
| 32 |
+
document_id="d1",
|
| 33 |
+
code_version="1.0.0",
|
| 34 |
+
pipeline_name="ocr_only",
|
| 35 |
+
)
|
| 36 |
+
assert ctx.workspace_uri is None
|
| 37 |
+
|
| 38 |
+
def test_with_workspace(self) -> None:
|
| 39 |
+
ctx = RunContext(
|
| 40 |
+
document_id="d1",
|
| 41 |
+
code_version="1.0.0",
|
| 42 |
+
pipeline_name="ocr_only",
|
| 43 |
+
workspace_uri="/tmp/picarones/runs/abc",
|
| 44 |
+
)
|
| 45 |
+
assert ctx.workspace_uri == "/tmp/picarones/runs/abc"
|
| 46 |
+
|
| 47 |
+
def test_frozen(self) -> None:
|
| 48 |
+
ctx = RunContext(document_id="d", code_version="v", pipeline_name="p")
|
| 49 |
+
with pytest.raises(Exception):
|
| 50 |
+
ctx.document_id = "x" # type: ignore[misc]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 54 |
+
# StepResult & PipelineResult
|
| 55 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class TestStepResult:
|
| 59 |
+
def test_success(self) -> None:
|
| 60 |
+
r = StepResult(
|
| 61 |
+
step_id="ocr",
|
| 62 |
+
succeeded=True,
|
| 63 |
+
duration_seconds=2.5,
|
| 64 |
+
produced_artifacts={"raw_text": "d1:ocr:raw_text"},
|
| 65 |
+
)
|
| 66 |
+
assert r.succeeded
|
| 67 |
+
assert r.error is None
|
| 68 |
+
|
| 69 |
+
def test_failure(self) -> None:
|
| 70 |
+
r = StepResult(
|
| 71 |
+
step_id="ocr",
|
| 72 |
+
succeeded=False,
|
| 73 |
+
duration_seconds=0.1,
|
| 74 |
+
error="Tesseract introuvable",
|
| 75 |
+
)
|
| 76 |
+
assert not r.succeeded
|
| 77 |
+
assert r.produced_artifacts == {}
|
| 78 |
+
assert r.error == "Tesseract introuvable"
|
| 79 |
+
|
| 80 |
+
def test_negative_duration_rejected(self) -> None:
|
| 81 |
+
with pytest.raises(Exception):
|
| 82 |
+
StepResult(step_id="x", succeeded=True, duration_seconds=-1.0)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TestPipelineResult:
|
| 86 |
+
def test_with_artifacts(self) -> None:
|
| 87 |
+
a = Artifact(id="d1:ocr:raw_text", document_id="d1",
|
| 88 |
+
type=ArtifactType.RAW_TEXT)
|
| 89 |
+
b = Artifact(id="d1:ocr:alto_xml", document_id="d1",
|
| 90 |
+
type=ArtifactType.ALTO_XML)
|
| 91 |
+
result = PipelineResult(
|
| 92 |
+
pipeline_name="ocr_only",
|
| 93 |
+
document_id="d1",
|
| 94 |
+
step_results=(
|
| 95 |
+
StepResult(step_id="ocr", succeeded=True, duration_seconds=1.0,
|
| 96 |
+
produced_artifacts={
|
| 97 |
+
"raw_text": a.id, "alto_xml": b.id,
|
| 98 |
+
}),
|
| 99 |
+
),
|
| 100 |
+
succeeded=True,
|
| 101 |
+
duration_seconds=1.05,
|
| 102 |
+
artifacts=(a, b),
|
| 103 |
+
)
|
| 104 |
+
assert result.step_result_by_id("ocr") is not None
|
| 105 |
+
assert result.step_result_by_id("missing") is None
|
| 106 |
+
text_arts = result.artifacts_of_type(ArtifactType.RAW_TEXT)
|
| 107 |
+
assert len(text_arts) == 1
|
| 108 |
+
assert text_arts[0].id == a.id
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 112 |
+
# StepExecutor protocol
|
| 113 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class _StubExecutor:
|
| 117 |
+
"""Minimum pour satisfaire ``StepExecutor``."""
|
| 118 |
+
|
| 119 |
+
name = "tesseract"
|
| 120 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 121 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 122 |
+
execution_mode = "cpu"
|
| 123 |
+
|
| 124 |
+
def execute(
|
| 125 |
+
self,
|
| 126 |
+
inputs: dict[ArtifactType, Artifact],
|
| 127 |
+
params: dict[str, str | int | float | bool],
|
| 128 |
+
context: RunContext,
|
| 129 |
+
) -> dict[ArtifactType, Artifact]:
|
| 130 |
+
image = inputs[ArtifactType.IMAGE]
|
| 131 |
+
return {
|
| 132 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 133 |
+
id=f"{context.document_id}:tesseract:raw_text",
|
| 134 |
+
document_id=context.document_id,
|
| 135 |
+
type=ArtifactType.RAW_TEXT,
|
| 136 |
+
produced_by_step="ocr",
|
| 137 |
+
),
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class TestStepExecutorProtocol:
|
| 142 |
+
def test_stub_satisfies_protocol(self) -> None:
|
| 143 |
+
ex = _StubExecutor()
|
| 144 |
+
assert isinstance(ex, StepExecutor)
|
| 145 |
+
|
| 146 |
+
def test_non_conforming_does_not_satisfy(self) -> None:
|
| 147 |
+
class _NotAnExecutor:
|
| 148 |
+
pass
|
| 149 |
+
assert not isinstance(_NotAnExecutor(), StepExecutor)
|
| 150 |
+
|
| 151 |
+
def test_stub_can_execute(self) -> None:
|
| 152 |
+
ex = _StubExecutor()
|
| 153 |
+
ctx = RunContext(document_id="d1", code_version="v", pipeline_name="p")
|
| 154 |
+
img = Artifact(id="d1:img", document_id="d1", type=ArtifactType.IMAGE)
|
| 155 |
+
out = ex.execute({ArtifactType.IMAGE: img}, {}, ctx)
|
| 156 |
+
assert ArtifactType.RAW_TEXT in out
|
| 157 |
+
assert out[ArtifactType.RAW_TEXT].document_id == "d1"
|
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S6 — ``PipelineStep``, ``PipelineSpec`` (déclaratifs)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from picarones.domain import ArtifactType, PicaronesError
|
| 8 |
+
from picarones.pipeline import INITIAL_STEP_ID, PipelineSpec, PipelineStep
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 12 |
+
# PipelineStep — validation des id et champs
|
| 13 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestPipelineStep:
|
| 17 |
+
def test_minimal_step(self) -> None:
|
| 18 |
+
s = PipelineStep(
|
| 19 |
+
id="ocr",
|
| 20 |
+
kind="ocr",
|
| 21 |
+
adapter_name="tesseract",
|
| 22 |
+
input_types=(ArtifactType.IMAGE,),
|
| 23 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 24 |
+
)
|
| 25 |
+
assert s.id == "ocr"
|
| 26 |
+
assert s.params == {}
|
| 27 |
+
assert s.inputs_from == {}
|
| 28 |
+
|
| 29 |
+
def test_step_with_inputs_from(self) -> None:
|
| 30 |
+
s = PipelineStep(
|
| 31 |
+
id="correction",
|
| 32 |
+
kind="post_correction",
|
| 33 |
+
adapter_name="openai:gpt-4o",
|
| 34 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 35 |
+
output_types=(ArtifactType.CORRECTED_TEXT,),
|
| 36 |
+
inputs_from={ArtifactType.RAW_TEXT: "ocr"},
|
| 37 |
+
)
|
| 38 |
+
assert s.inputs_from[ArtifactType.RAW_TEXT] == "ocr"
|
| 39 |
+
|
| 40 |
+
def test_step_with_params(self) -> None:
|
| 41 |
+
s = PipelineStep(
|
| 42 |
+
id="ocr",
|
| 43 |
+
kind="ocr",
|
| 44 |
+
adapter_name="tesseract",
|
| 45 |
+
params={"lang": "fra", "psm": 6, "preserve_interword_spaces": True},
|
| 46 |
+
)
|
| 47 |
+
assert s.params["lang"] == "fra"
|
| 48 |
+
assert s.params["psm"] == 6
|
| 49 |
+
|
| 50 |
+
def test_id_validation_rejects_space(self) -> None:
|
| 51 |
+
with pytest.raises(PicaronesError, match="step id invalide"):
|
| 52 |
+
PipelineStep(id="bad id", kind="x", adapter_name="y")
|
| 53 |
+
|
| 54 |
+
def test_id_validation_rejects_dot(self) -> None:
|
| 55 |
+
with pytest.raises(PicaronesError, match="step id invalide"):
|
| 56 |
+
PipelineStep(id="bad.id", kind="x", adapter_name="y")
|
| 57 |
+
|
| 58 |
+
def test_id_validation_rejects_initial_sentinel(self) -> None:
|
| 59 |
+
"""``__initial__`` est réservé pour désigner les entrées
|
| 60 |
+
initiales du runner — un step ne peut pas porter ce nom."""
|
| 61 |
+
with pytest.raises(PicaronesError, match="réservé"):
|
| 62 |
+
PipelineStep(id=INITIAL_STEP_ID, kind="x", adapter_name="y")
|
| 63 |
+
|
| 64 |
+
def test_id_accepts_alphanum_underscore_dash(self) -> None:
|
| 65 |
+
s = PipelineStep(id="step_1-final", kind="x", adapter_name="y")
|
| 66 |
+
assert s.id == "step_1-final"
|
| 67 |
+
|
| 68 |
+
def test_frozen(self) -> None:
|
| 69 |
+
s = PipelineStep(id="a", kind="b", adapter_name="c")
|
| 70 |
+
with pytest.raises(Exception):
|
| 71 |
+
s.id = "d" # type: ignore[misc]
|
| 72 |
+
|
| 73 |
+
def test_extra_field_rejected(self) -> None:
|
| 74 |
+
with pytest.raises(Exception):
|
| 75 |
+
PipelineStep( # type: ignore[call-arg]
|
| 76 |
+
id="a", kind="b", adapter_name="c", bogus=42,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 81 |
+
# PipelineSpec
|
| 82 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TestPipelineSpec:
|
| 86 |
+
def test_minimal_spec(self) -> None:
|
| 87 |
+
s = PipelineSpec(name="empty")
|
| 88 |
+
assert s.name == "empty"
|
| 89 |
+
assert s.steps == ()
|
| 90 |
+
assert s.initial_inputs == ()
|
| 91 |
+
|
| 92 |
+
def test_spec_with_steps(self) -> None:
|
| 93 |
+
s = PipelineSpec(
|
| 94 |
+
name="ocr_only",
|
| 95 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 96 |
+
steps=(
|
| 97 |
+
PipelineStep(
|
| 98 |
+
id="ocr",
|
| 99 |
+
kind="ocr",
|
| 100 |
+
adapter_name="tesseract",
|
| 101 |
+
input_types=(ArtifactType.IMAGE,),
|
| 102 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 103 |
+
),
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
assert len(s.steps) == 1
|
| 107 |
+
assert s.step_by_id("ocr") is not None
|
| 108 |
+
assert s.step_by_id("missing") is None
|
| 109 |
+
|
| 110 |
+
def test_frozen(self) -> None:
|
| 111 |
+
s = PipelineSpec(name="x")
|
| 112 |
+
with pytest.raises(Exception):
|
| 113 |
+
s.name = "y" # type: ignore[misc]
|
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S6 — ``validate_spec``.
|
| 2 |
+
|
| 3 |
+
Couvre les ~12 cas typiques : chaîne valide, type manquant,
|
| 4 |
+
adapter inconnu, fork avec ``inputs_from``, références invalides,
|
| 5 |
+
DAG vide, IDs dupliqués.
|
| 6 |
+
|
| 7 |
+
Aucun ``StepExecutor`` instancié — la validation est purement
|
| 8 |
+
statique sur la spec.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from picarones.domain import ArtifactType
|
| 14 |
+
from picarones.pipeline import (
|
| 15 |
+
INITIAL_STEP_ID,
|
| 16 |
+
PipelineSpec,
|
| 17 |
+
PipelineStep,
|
| 18 |
+
validate_spec,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 23 |
+
# Cas valides
|
| 24 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class TestValidSpecs:
|
| 28 |
+
def test_simple_ocr_pipeline(self) -> None:
|
| 29 |
+
spec = PipelineSpec(
|
| 30 |
+
name="ocr_only",
|
| 31 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 32 |
+
steps=(
|
| 33 |
+
PipelineStep(
|
| 34 |
+
id="ocr", kind="ocr", adapter_name="tesseract",
|
| 35 |
+
input_types=(ArtifactType.IMAGE,),
|
| 36 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 37 |
+
),
|
| 38 |
+
),
|
| 39 |
+
)
|
| 40 |
+
assert validate_spec(spec) == []
|
| 41 |
+
|
| 42 |
+
def test_ocr_then_llm(self) -> None:
|
| 43 |
+
spec = PipelineSpec(
|
| 44 |
+
name="ocr_llm",
|
| 45 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 46 |
+
steps=(
|
| 47 |
+
PipelineStep(
|
| 48 |
+
id="ocr", kind="ocr", adapter_name="tesseract",
|
| 49 |
+
input_types=(ArtifactType.IMAGE,),
|
| 50 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 51 |
+
),
|
| 52 |
+
PipelineStep(
|
| 53 |
+
id="correct", kind="post_correction",
|
| 54 |
+
adapter_name="openai:gpt-4o",
|
| 55 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 56 |
+
output_types=(ArtifactType.CORRECTED_TEXT,),
|
| 57 |
+
),
|
| 58 |
+
),
|
| 59 |
+
)
|
| 60 |
+
assert validate_spec(spec) == []
|
| 61 |
+
|
| 62 |
+
def test_def_of_done_tesseract_llm_alto_remap(self) -> None:
|
| 63 |
+
"""Définition de done du S6 : valider le YAML cible BnF."""
|
| 64 |
+
spec = PipelineSpec(
|
| 65 |
+
name="tesseract_llm_alto_remap",
|
| 66 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 67 |
+
steps=(
|
| 68 |
+
PipelineStep(
|
| 69 |
+
id="ocr", kind="ocr", adapter_name="tesseract",
|
| 70 |
+
input_types=(ArtifactType.IMAGE,),
|
| 71 |
+
output_types=(ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML),
|
| 72 |
+
),
|
| 73 |
+
PipelineStep(
|
| 74 |
+
id="correction", kind="post_correction",
|
| 75 |
+
adapter_name="openai:gpt-4o",
|
| 76 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 77 |
+
output_types=(ArtifactType.CORRECTED_TEXT,),
|
| 78 |
+
inputs_from={ArtifactType.RAW_TEXT: "ocr"},
|
| 79 |
+
),
|
| 80 |
+
PipelineStep(
|
| 81 |
+
id="alto_remap", kind="alto_remapping",
|
| 82 |
+
adapter_name="picarones-contrib:line_remapper",
|
| 83 |
+
input_types=(
|
| 84 |
+
ArtifactType.CORRECTED_TEXT, ArtifactType.ALTO_XML,
|
| 85 |
+
),
|
| 86 |
+
output_types=(ArtifactType.ALTO_XML,),
|
| 87 |
+
inputs_from={
|
| 88 |
+
ArtifactType.CORRECTED_TEXT: "correction",
|
| 89 |
+
ArtifactType.ALTO_XML: "ocr",
|
| 90 |
+
},
|
| 91 |
+
),
|
| 92 |
+
),
|
| 93 |
+
)
|
| 94 |
+
assert validate_spec(spec) == []
|
| 95 |
+
|
| 96 |
+
def test_inputs_from_initial_explicit(self) -> None:
|
| 97 |
+
"""Une étape peut référencer explicitement les entrées
|
| 98 |
+
initiales via ``__initial__``."""
|
| 99 |
+
spec = PipelineSpec(
|
| 100 |
+
name="explicit_initial",
|
| 101 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 102 |
+
steps=(
|
| 103 |
+
PipelineStep(
|
| 104 |
+
id="ocr", kind="ocr", adapter_name="tesseract",
|
| 105 |
+
input_types=(ArtifactType.IMAGE,),
|
| 106 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 107 |
+
inputs_from={ArtifactType.IMAGE: INITIAL_STEP_ID},
|
| 108 |
+
),
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
assert validate_spec(spec) == []
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 115 |
+
# Cas invalides
|
| 116 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
class TestInvalidSpecs:
|
| 120 |
+
def test_empty_pipeline(self) -> None:
|
| 121 |
+
spec = PipelineSpec(name="empty")
|
| 122 |
+
errors = validate_spec(spec)
|
| 123 |
+
assert len(errors) == 1
|
| 124 |
+
assert errors[0].code == "empty_pipeline"
|
| 125 |
+
|
| 126 |
+
def test_missing_input_no_initial(self) -> None:
|
| 127 |
+
"""Une étape qui demande IMAGE mais initial_inputs vide."""
|
| 128 |
+
spec = PipelineSpec(
|
| 129 |
+
name="missing_image",
|
| 130 |
+
initial_inputs=(),
|
| 131 |
+
steps=(
|
| 132 |
+
PipelineStep(
|
| 133 |
+
id="ocr", kind="ocr", adapter_name="tesseract",
|
| 134 |
+
input_types=(ArtifactType.IMAGE,),
|
| 135 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 136 |
+
),
|
| 137 |
+
),
|
| 138 |
+
)
|
| 139 |
+
errors = validate_spec(spec)
|
| 140 |
+
codes = [e.code for e in errors]
|
| 141 |
+
assert "missing_input" in codes
|
| 142 |
+
|
| 143 |
+
def test_missing_input_step_order_wrong(self) -> None:
|
| 144 |
+
"""L'étape de correction est avant l'OCR — le RAW_TEXT n'existe
|
| 145 |
+
pas encore."""
|
| 146 |
+
spec = PipelineSpec(
|
| 147 |
+
name="wrong_order",
|
| 148 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 149 |
+
steps=(
|
| 150 |
+
PipelineStep(
|
| 151 |
+
id="correct", kind="post_correction",
|
| 152 |
+
adapter_name="openai",
|
| 153 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 154 |
+
output_types=(ArtifactType.CORRECTED_TEXT,),
|
| 155 |
+
),
|
| 156 |
+
PipelineStep(
|
| 157 |
+
id="ocr", kind="ocr", adapter_name="tesseract",
|
| 158 |
+
input_types=(ArtifactType.IMAGE,),
|
| 159 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 160 |
+
),
|
| 161 |
+
),
|
| 162 |
+
)
|
| 163 |
+
errors = validate_spec(spec)
|
| 164 |
+
codes = [e.code for e in errors]
|
| 165 |
+
assert "missing_input" in codes
|
| 166 |
+
# La première étape (correct) doit être le step_id signalé.
|
| 167 |
+
missing = [e for e in errors if e.code == "missing_input"]
|
| 168 |
+
assert any(e.step_id == "correct" for e in missing)
|
| 169 |
+
|
| 170 |
+
def test_duplicate_step_id(self) -> None:
|
| 171 |
+
spec = PipelineSpec(
|
| 172 |
+
name="dup",
|
| 173 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 174 |
+
steps=(
|
| 175 |
+
PipelineStep(
|
| 176 |
+
id="step", kind="ocr", adapter_name="a",
|
| 177 |
+
input_types=(ArtifactType.IMAGE,),
|
| 178 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 179 |
+
),
|
| 180 |
+
PipelineStep(
|
| 181 |
+
id="step", kind="post_correction", adapter_name="b",
|
| 182 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 183 |
+
output_types=(ArtifactType.CORRECTED_TEXT,),
|
| 184 |
+
),
|
| 185 |
+
),
|
| 186 |
+
)
|
| 187 |
+
errors = validate_spec(spec)
|
| 188 |
+
codes = [e.code for e in errors]
|
| 189 |
+
assert "duplicate_id" in codes
|
| 190 |
+
|
| 191 |
+
def test_unknown_adapter_when_registry_provided(self) -> None:
|
| 192 |
+
spec = PipelineSpec(
|
| 193 |
+
name="unknown",
|
| 194 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 195 |
+
steps=(
|
| 196 |
+
PipelineStep(
|
| 197 |
+
id="ocr", kind="ocr", adapter_name="not_in_registry",
|
| 198 |
+
input_types=(ArtifactType.IMAGE,),
|
| 199 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 200 |
+
),
|
| 201 |
+
),
|
| 202 |
+
)
|
| 203 |
+
errors = validate_spec(spec, available_adapters={"tesseract"})
|
| 204 |
+
codes = [e.code for e in errors]
|
| 205 |
+
assert "unknown_adapter" in codes
|
| 206 |
+
|
| 207 |
+
def test_no_adapter_check_when_registry_none(self) -> None:
|
| 208 |
+
"""Si available_adapters=None, on ne vérifie pas les adapters."""
|
| 209 |
+
spec = PipelineSpec(
|
| 210 |
+
name="x",
|
| 211 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 212 |
+
steps=(
|
| 213 |
+
PipelineStep(
|
| 214 |
+
id="ocr", kind="ocr", adapter_name="not_registered_anywhere",
|
| 215 |
+
input_types=(ArtifactType.IMAGE,),
|
| 216 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 217 |
+
),
|
| 218 |
+
),
|
| 219 |
+
)
|
| 220 |
+
errors = validate_spec(spec) # registry=None
|
| 221 |
+
codes = [e.code for e in errors]
|
| 222 |
+
assert "unknown_adapter" not in codes
|
| 223 |
+
|
| 224 |
+
def test_inputs_from_unused_type(self) -> None:
|
| 225 |
+
"""Une étape déclare ``inputs_from[X]`` mais X n'est pas dans
|
| 226 |
+
son ``input_types``."""
|
| 227 |
+
spec = PipelineSpec(
|
| 228 |
+
name="x",
|
| 229 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 230 |
+
steps=(
|
| 231 |
+
PipelineStep(
|
| 232 |
+
id="ocr", kind="ocr", adapter_name="tess",
|
| 233 |
+
input_types=(ArtifactType.IMAGE,),
|
| 234 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 235 |
+
inputs_from={ArtifactType.ALTO_XML: INITIAL_STEP_ID},
|
| 236 |
+
),
|
| 237 |
+
),
|
| 238 |
+
)
|
| 239 |
+
errors = validate_spec(spec)
|
| 240 |
+
codes = [e.code for e in errors]
|
| 241 |
+
assert "inputs_from_unused" in codes
|
| 242 |
+
|
| 243 |
+
def test_unknown_input_source(self) -> None:
|
| 244 |
+
"""``inputs_from[type] = "ghost"`` mais ``ghost`` n'existe pas."""
|
| 245 |
+
spec = PipelineSpec(
|
| 246 |
+
name="x",
|
| 247 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 248 |
+
steps=(
|
| 249 |
+
PipelineStep(
|
| 250 |
+
id="ocr", kind="ocr", adapter_name="tess",
|
| 251 |
+
input_types=(ArtifactType.IMAGE,),
|
| 252 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 253 |
+
inputs_from={ArtifactType.IMAGE: "ghost"},
|
| 254 |
+
),
|
| 255 |
+
),
|
| 256 |
+
)
|
| 257 |
+
errors = validate_spec(spec)
|
| 258 |
+
codes = [e.code for e in errors]
|
| 259 |
+
assert "unknown_input_source" in codes
|
| 260 |
+
|
| 261 |
+
def test_source_does_not_produce_type(self) -> None:
|
| 262 |
+
"""``inputs_from[ALTO_XML] = "ocr"`` mais ``ocr`` ne produit que
|
| 263 |
+
``RAW_TEXT``."""
|
| 264 |
+
spec = PipelineSpec(
|
| 265 |
+
name="x",
|
| 266 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 267 |
+
steps=(
|
| 268 |
+
PipelineStep(
|
| 269 |
+
id="ocr", kind="ocr", adapter_name="tess",
|
| 270 |
+
input_types=(ArtifactType.IMAGE,),
|
| 271 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 272 |
+
),
|
| 273 |
+
PipelineStep(
|
| 274 |
+
id="alto_consumer", kind="x", adapter_name="y",
|
| 275 |
+
input_types=(ArtifactType.ALTO_XML,),
|
| 276 |
+
output_types=(ArtifactType.ALTO_XML,),
|
| 277 |
+
inputs_from={ArtifactType.ALTO_XML: "ocr"},
|
| 278 |
+
),
|
| 279 |
+
),
|
| 280 |
+
)
|
| 281 |
+
errors = validate_spec(spec)
|
| 282 |
+
codes = [e.code for e in errors]
|
| 283 |
+
assert "source_does_not_produce_type" in codes
|
| 284 |
+
# En plus, ALTO_XML n'est pas disponible dans le bag → missing_input
|
| 285 |
+
# peut aussi être levé.
|
| 286 |
+
|
| 287 |
+
def test_multiple_errors_at_once(self) -> None:
|
| 288 |
+
"""``validate_spec`` ne s'arrête pas à la première erreur."""
|
| 289 |
+
spec = PipelineSpec(
|
| 290 |
+
name="multi_errors",
|
| 291 |
+
initial_inputs=(),
|
| 292 |
+
steps=(
|
| 293 |
+
PipelineStep(
|
| 294 |
+
id="dup", kind="x", adapter_name="a",
|
| 295 |
+
input_types=(ArtifactType.IMAGE,),
|
| 296 |
+
output_types=(),
|
| 297 |
+
),
|
| 298 |
+
PipelineStep(
|
| 299 |
+
id="dup", kind="y", adapter_name="b",
|
| 300 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 301 |
+
output_types=(),
|
| 302 |
+
),
|
| 303 |
+
),
|
| 304 |
+
)
|
| 305 |
+
errors = validate_spec(spec)
|
| 306 |
+
codes = [e.code for e in errors]
|
| 307 |
+
assert "duplicate_id" in codes
|
| 308 |
+
assert "missing_input" in codes # IMAGE et RAW_TEXT manquants
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A14-S6 — round-trip YAML d'une ``PipelineSpec``.
|
| 2 |
+
|
| 3 |
+
Garantit que ``dump_spec_to_yaml(spec)`` produit du YAML qui se
|
| 4 |
+
recharge en une spec strictement égale. C'est la propriété qui
|
| 5 |
+
permet de versionner les pipelines en git de façon
|
| 6 |
+
human-readable + machine-actionable.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
from picarones.domain import ArtifactType, PicaronesError
|
| 14 |
+
from picarones.pipeline import (
|
| 15 |
+
PipelineSpec,
|
| 16 |
+
PipelineStep,
|
| 17 |
+
dump_spec_to_yaml,
|
| 18 |
+
load_spec_from_yaml,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _ocr_only_spec() -> PipelineSpec:
|
| 23 |
+
return PipelineSpec(
|
| 24 |
+
name="ocr_only",
|
| 25 |
+
description="Tesseract sur image patrimoniale.",
|
| 26 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 27 |
+
steps=(
|
| 28 |
+
PipelineStep(
|
| 29 |
+
id="ocr",
|
| 30 |
+
kind="ocr",
|
| 31 |
+
adapter_name="tesseract",
|
| 32 |
+
params={"lang": "fra", "psm": 6},
|
| 33 |
+
input_types=(ArtifactType.IMAGE,),
|
| 34 |
+
output_types=(ArtifactType.RAW_TEXT,),
|
| 35 |
+
),
|
| 36 |
+
),
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _full_pipeline_spec() -> PipelineSpec:
|
| 41 |
+
return PipelineSpec(
|
| 42 |
+
name="tesseract_llm_alto_remap",
|
| 43 |
+
description="OCR + LLM + remapping ALTO (cas BnF central).",
|
| 44 |
+
initial_inputs=(ArtifactType.IMAGE,),
|
| 45 |
+
steps=(
|
| 46 |
+
PipelineStep(
|
| 47 |
+
id="ocr",
|
| 48 |
+
kind="ocr",
|
| 49 |
+
adapter_name="tesseract",
|
| 50 |
+
params={"lang": "fra"},
|
| 51 |
+
input_types=(ArtifactType.IMAGE,),
|
| 52 |
+
output_types=(ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML),
|
| 53 |
+
),
|
| 54 |
+
PipelineStep(
|
| 55 |
+
id="correction",
|
| 56 |
+
kind="post_correction",
|
| 57 |
+
adapter_name="openai:gpt-4o",
|
| 58 |
+
params={"temperature": 0.0, "max_tokens": 4096},
|
| 59 |
+
input_types=(ArtifactType.RAW_TEXT,),
|
| 60 |
+
output_types=(ArtifactType.CORRECTED_TEXT,),
|
| 61 |
+
inputs_from={ArtifactType.RAW_TEXT: "ocr"},
|
| 62 |
+
),
|
| 63 |
+
PipelineStep(
|
| 64 |
+
id="alto_remap",
|
| 65 |
+
kind="alto_remapping",
|
| 66 |
+
adapter_name="picarones-contrib:line_remapper",
|
| 67 |
+
input_types=(
|
| 68 |
+
ArtifactType.CORRECTED_TEXT, ArtifactType.ALTO_XML,
|
| 69 |
+
),
|
| 70 |
+
output_types=(ArtifactType.ALTO_XML,),
|
| 71 |
+
inputs_from={
|
| 72 |
+
ArtifactType.CORRECTED_TEXT: "correction",
|
| 73 |
+
ArtifactType.ALTO_XML: "ocr",
|
| 74 |
+
},
|
| 75 |
+
),
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TestYAMLRoundtrip:
|
| 81 |
+
@pytest.mark.parametrize("spec_factory", [_ocr_only_spec, _full_pipeline_spec])
|
| 82 |
+
def test_roundtrip_preserves_equality(self, spec_factory) -> None:
|
| 83 |
+
spec = spec_factory()
|
| 84 |
+
yml = dump_spec_to_yaml(spec)
|
| 85 |
+
spec2 = load_spec_from_yaml(yml)
|
| 86 |
+
assert spec == spec2
|
| 87 |
+
|
| 88 |
+
def test_roundtrip_is_idempotent(self) -> None:
|
| 89 |
+
"""Dump → Load → Dump produit le même YAML byte-pour-byte."""
|
| 90 |
+
spec = _full_pipeline_spec()
|
| 91 |
+
yml1 = dump_spec_to_yaml(spec)
|
| 92 |
+
spec2 = load_spec_from_yaml(yml1)
|
| 93 |
+
yml2 = dump_spec_to_yaml(spec2)
|
| 94 |
+
assert yml1 == yml2
|
| 95 |
+
|
| 96 |
+
def test_yaml_is_human_readable(self) -> None:
|
| 97 |
+
"""Le YAML produit doit utiliser le style 'block' (un champ
|
| 98 |
+
par ligne), pas le style 'flow' (JSON-like)."""
|
| 99 |
+
yml = dump_spec_to_yaml(_full_pipeline_spec())
|
| 100 |
+
assert "name: tesseract_llm_alto_remap" in yml
|
| 101 |
+
assert "steps:" in yml
|
| 102 |
+
# Pas de "{" pour signaler le style block.
|
| 103 |
+
# Les ``params`` peuvent encore contenir des ``{}`` quand le
|
| 104 |
+
# dict est vide ; on vérifie juste que le format général
|
| 105 |
+
# est lisible.
|
| 106 |
+
assert "- id: ocr" in yml
|
| 107 |
+
|
| 108 |
+
def test_empty_yaml_raises(self) -> None:
|
| 109 |
+
with pytest.raises(PicaronesError, match="vide"):
|
| 110 |
+
load_spec_from_yaml("")
|
| 111 |
+
|
| 112 |
+
def test_yaml_ordered_fields(self) -> None:
|
| 113 |
+
"""``sort_keys=False`` doit être respecté."""
|
| 114 |
+
yml = dump_spec_to_yaml(_ocr_only_spec())
|
| 115 |
+
# Dans la spec, ``name`` apparaît avant ``description``,
|
| 116 |
+
# ``initial_inputs`` avant ``steps``.
|
| 117 |
+
i_name = yml.index("name:")
|
| 118 |
+
i_desc = yml.index("description:")
|
| 119 |
+
i_init = yml.index("initial_inputs:")
|
| 120 |
+
i_steps = yml.index("steps:")
|
| 121 |
+
assert i_name < i_desc < i_init < i_steps
|
| 122 |
+
|
| 123 |
+
def test_invalid_yaml_raises(self) -> None:
|
| 124 |
+
"""Un YAML qui ne respecte pas le schéma de PipelineSpec
|
| 125 |
+
lève une ValidationError pydantic."""
|
| 126 |
+
bad = "name: x\nsteps:\n - id: ocr\n kind: ocr\n adapter_name: x\n input_types: [bogus_type]\n"
|
| 127 |
+
with pytest.raises(Exception): # pydantic ValidationError
|
| 128 |
+
load_spec_from_yaml(bad)
|