Claude commited on
Commit
f894bf0
·
unverified ·
1 Parent(s): 8f6b234

feat(6-volet2): make_ocr_llm_pipeline_spec — convergence des 3 modes vers PipelineSpec

Browse files

Phase 6 volet 2 — fondation pour le retrait de
``picarones.pipelines.base.OCRLLMPipeline``.

Le builder ``make_ocr_llm_pipeline_spec(mode, ocr_adapter_name,
llm_adapter_name)`` convertit les 3 modes historiques (``text_only``,
``text_and_image``, ``zero_shot``) en ``PipelineSpec`` canoniques
exécutables par ``PipelineExecutor``. C'est le pont entre l'API
legacy et le rewrite (Sprints A14-S6/S7/S44/S45).

Découverte
----------
Audit de l'infrastructure rewrite — ``BaseLLMAdapter`` (couche
``adapters/llm/``) et ``BaseVLMAdapter`` (couche ``adapters/vlm/``)
implémentent **déjà** le contrat ``StepExecutor`` (depuis A14-S44/S45) :

- ``BaseLLMAdapter`` : ``RAW_TEXT`` → ``CORRECTED_TEXT`` (+ ``IMAGE``
optionnelle pour mode VLM).
- ``BaseVLMAdapter`` : ``IMAGE`` → ``RAW_TEXT``.

Ces deux contrats couvrent exactement les 3 modes de
``OCRLLMPipeline``. Le travail volet 2 ne nécessite **pas** de
créer de nouveaux adapters — juste de fournir le builder de
``PipelineSpec`` qui assemble les briques existantes.

Mapping mode → spec
-------------------
| Mode legacy | Initial inputs | Steps | Output final |
|--------------------|----------------|---------------|------------------|
| ``text_only`` | IMAGE | OCR + LLM | CORRECTED_TEXT |
| ``text_and_image`` | IMAGE | OCR + LLM* | CORRECTED_TEXT |
| ``zero_shot`` | IMAGE | VLM seul | RAW_TEXT |

(* en mode ``text_and_image``, le step LLM consomme aussi ``IMAGE``
depuis ``__initial__``, en plus du ``RAW_TEXT`` issu de l'OCR.)

API
---
- ``picarones.pipeline.make_ocr_llm_pipeline_spec(...)`` —
fonction publique, ré-exportée depuis ``picarones.pipeline``.
- ``picarones.pipeline.OCRLLMPipelineMode`` — type
``Literal["text_only", "text_and_image", "zero_shot"]``.
- Le builder valide les combinaisons :
``zero_shot`` + ``ocr_adapter_name`` lève ``PicaronesError`` ;
``text_only`` ou ``text_and_image`` sans ``ocr_adapter_name``
lèvent aussi.
- Auto-naming : ``ocr_llm_<mode>_<ocr>_to_<llm>`` ou
``vlm_zero_shot_<llm>``.

Tests
-----
``tests/pipeline/test_phase6_volet2_llm_pipeline_builder.py`` —
26 tests couvrant :

- Structure du DAG pour chacun des 3 modes (1 ou 2 steps,
``inputs_from`` correctement câblé).
- Types d'artefacts produits/consommés à chaque step.
- ``validate_spec`` accepte les 3 specs sans erreur.
- Erreurs sur combinaisons invalides.
- Auto-naming (incluant l'échappement des ``:`` dans les
noms d'adapter LLM).
- Round-trip YAML (les specs traversent ``dump_spec_to_yaml``
/ ``load_spec_from_yaml`` sans perte).

Migration future (sub-phases 6.B+)
----------------------------------
Avec ce builder en place, les 3 callers internes de
``OCRLLMPipeline`` peuvent migrer un à un :

1. ``picarones/web/benchmark_utils.py:131`` — instancie
``OCRLLMPipeline(...)`` ; remplaçable par
``make_ocr_llm_pipeline_spec(...)`` + ``PipelineExecutor.run``
(via ``RunOrchestrator``).
2. ``picarones/measurements/runner/orchestration.py:520-521`` —
``isinstance(engine, OCRLLMPipeline)`` ; remplaçable par
un check ``is_pipeline`` au niveau ``PipelineSpec``.
3. ``picarones/fixtures.py`` (callers indirects via runner).

Quand les 3 callers consomment des ``PipelineSpec``, le
``OCRLLMPipeline`` legacy peut être supprimé. Ce travail
incrémental sortira d'un commit ``feat(6-volet2-N)`` séparé pour
chaque caller.

Bilan
-----
- ``pytest tests/`` : 4740 passed (+25), 0 failed.
- ``ruff check`` : clean.
- 1 module créé (245 LOC), 1 fichier de tests créé (264 LOC),
``pipeline/__init__.py`` exporte 2 symboles supplémentaires.
- Aucun caller existant n'est touché — l'API legacy
``OCRLLMPipeline`` reste exécutable et inchangée pour
cette session.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

CLAUDE.md CHANGED
@@ -123,7 +123,7 @@ picarones/
123
 
124
  ## État des tests et bugs historiques
125
 
126
- `pytest tests/` → **4750 passed, 12 skipped, 8 deselected, 0 failed**
127
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
128
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
129
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
@@ -253,7 +253,7 @@ Résumé express :
253
 
254
  1. `git branch --show-current` → `claude/repo-analysis-cukvm`.
255
  2. `git status` → working tree clean.
256
- 3. `pytest tests/ -q --no-header --tb=line` → 4750 passed.
257
  4. `git log -1 --format=%B` → décrit la prochaine sub-phase.
258
 
259
  **Règles d'architecture critiques** (apprises à la dure) :
@@ -341,7 +341,7 @@ détecte, arbitre, rend.
341
  ## Contexte développement
342
 
343
  - **Environnement** : GitHub Codespaces, Python 3.11+
344
- - **Tests** : `pytest tests/ -q` → 4750 passed, 12 skipped, 24
345
  deselected, 0 failed (au moment de la pause de session).
346
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
347
  - **Plan retrait du legacy (maître)** : [`docs/migration/legacy-retirement-plan.md`](docs/migration/legacy-retirement-plan.md).
 
123
 
124
  ## État des tests et bugs historiques
125
 
126
+ `pytest tests/` → **4770 passed, 12 skipped, 8 deselected, 0 failed**
127
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
128
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
129
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
 
253
 
254
  1. `git branch --show-current` → `claude/repo-analysis-cukvm`.
255
  2. `git status` → working tree clean.
256
+ 3. `pytest tests/ -q --no-header --tb=line` → 4770 passed.
257
  4. `git log -1 --format=%B` → décrit la prochaine sub-phase.
258
 
259
  **Règles d'architecture critiques** (apprises à la dure) :
 
341
  ## Contexte développement
342
 
343
  - **Environnement** : GitHub Codespaces, Python 3.11+
344
+ - **Tests** : `pytest tests/ -q` → 4770 passed, 12 skipped, 24
345
  deselected, 0 failed (au moment de la pause de session).
346
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md).
347
  - **Plan retrait du legacy (maître)** : [`docs/migration/legacy-retirement-plan.md`](docs/migration/legacy-retirement-plan.md).
README.md CHANGED
@@ -394,7 +394,7 @@ ruff check picarones/ tests/
394
  python -m mypy picarones/core/
395
  ```
396
 
397
- **Test suite**: ~4750 tests, ~3 min on a modern laptop. Coverage
398
  floor at 85% (currently ~87%). The `network` marker excludes tests
399
  requiring live HTTP. A handful of tests depend on optional engines
400
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
394
  python -m mypy picarones/core/
395
  ```
396
 
397
+ **Test suite**: ~4770 tests, ~3 min on a modern laptop. Coverage
398
  floor at 85% (currently ~87%). The `network` marker excludes tests
399
  requiring live HTTP. A handful of tests depend on optional engines
400
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/pipeline/__init__.py CHANGED
@@ -56,6 +56,10 @@ from picarones.pipeline.executor import (
56
  PipelineExecutor,
57
  PipelineSpecInvalid,
58
  )
 
 
 
 
59
  from picarones.pipeline.planner import (
60
  ExecutionPlan,
61
  MetricJunction,
@@ -99,6 +103,9 @@ __all__ = [
99
  "PipelineExecutor",
100
  "PipelineSpecInvalid",
101
  "AdapterResolver",
 
 
 
102
  # Planner (S28)
103
  "PipelinePlanner",
104
  "PlanningError",
 
56
  PipelineExecutor,
57
  PipelineSpecInvalid,
58
  )
59
+ from picarones.pipeline.llm_pipeline_builder import (
60
+ OCRLLMPipelineMode,
61
+ make_ocr_llm_pipeline_spec,
62
+ )
63
  from picarones.pipeline.planner import (
64
  ExecutionPlan,
65
  MetricJunction,
 
103
  "PipelineExecutor",
104
  "PipelineSpecInvalid",
105
  "AdapterResolver",
106
+ # Builder OCR+LLM (Phase 6 volet 2)
107
+ "make_ocr_llm_pipeline_spec",
108
+ "OCRLLMPipelineMode",
109
  # Planner (S28)
110
  "PipelinePlanner",
111
  "PlanningError",
picarones/pipeline/llm_pipeline_builder.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Builder de ``PipelineSpec`` pour les chaînes OCR + LLM (Phase 6 volet 2).
2
+
3
+ Ce module fournit la convergence entre les 3 modes historiques de
4
+ ``picarones.pipelines.base.OCRLLMPipeline`` (legacy) et la
5
+ ``PipelineSpec`` canonique exécutable par ``PipelineExecutor``.
6
+
7
+ Mapping mode legacy → spec canonique
8
+ ------------------------------------
9
+
10
+ ================ ============= =========== ================================
11
+ Mode legacy Initial input Steps Output final
12
+ ================ ============= =========== ================================
13
+ ``text_only`` IMAGE OCR + LLM ``CORRECTED_TEXT``
14
+ ``text_and_image`` IMAGE OCR + LLM ``CORRECTED_TEXT`` (LLM voit aussi IMAGE)
15
+ ``zero_shot`` IMAGE VLM seul ``RAW_TEXT``
16
+ ================ ============= =========== ================================
17
+
18
+ Les 3 modes correspondent aux contrats ``StepExecutor`` :
19
+
20
+ - ``BaseLLMAdapter`` (texte → texte corrigé) — couvre ``text_only``
21
+ et ``text_and_image`` car son ``execute()`` lit l'image
22
+ optionnellement présente dans le bag d'inputs.
23
+ - ``BaseVLMAdapter`` (image → texte) — couvre ``zero_shot``.
24
+
25
+ L'adapter OCR amont (Tesseract, Pero, Mistral OCR, Google Vision,
26
+ Azure DI, ou ``precomputed`` quand le corpus porte déjà l'OCR) est
27
+ quelconque tant qu'il déclare ``output_types ⊇ {RAW_TEXT}``.
28
+
29
+ Exemple de migration
30
+ --------------------
31
+ Code legacy ::
32
+
33
+ from picarones.pipelines import OCRLLMPipeline, PipelineMode
34
+ from picarones.adapters.legacy_engines.tesseract import TesseractEngine
35
+ from picarones.adapters.llm import OpenAIAdapter
36
+
37
+ pipeline = OCRLLMPipeline(
38
+ ocr_engine=TesseractEngine({"lang": "fra"}),
39
+ llm_adapter=OpenAIAdapter(model="gpt-4o"),
40
+ mode=PipelineMode.TEXT_ONLY,
41
+ )
42
+ result = pipeline.run("scan.jpg") # → EngineResult
43
+
44
+ Code canonique équivalent ::
45
+
46
+ from picarones.pipeline import PipelineExecutor
47
+ from picarones.pipeline.llm_pipeline_builder import (
48
+ make_ocr_llm_pipeline_spec,
49
+ )
50
+
51
+ spec = make_ocr_llm_pipeline_spec(
52
+ mode="text_only",
53
+ ocr_adapter_name="tesseract",
54
+ llm_adapter_name="openai:gpt-4o",
55
+ )
56
+ executor = PipelineExecutor(adapter_resolver=resolver, ...)
57
+ result = executor.run(spec, document, initial_inputs={IMAGE: ...}, context=...)
58
+
59
+ Le runtime résout les ``adapter_name`` en instances via le
60
+ ``adapter_resolver`` du caller (cf. ``picarones.app.services.run_orchestrator``).
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ from typing import Literal
66
+
67
+ from picarones.domain.artifacts import ArtifactType
68
+ from picarones.domain.errors import PicaronesError
69
+ from picarones.domain.pipeline_spec import (
70
+ INITIAL_STEP_ID,
71
+ PipelineSpec,
72
+ PipelineStep,
73
+ )
74
+
75
+
76
+ #: Modes supportés — alignés sur ``picarones.pipelines.base.PipelineMode``.
77
+ OCRLLMPipelineMode = Literal["text_only", "text_and_image", "zero_shot"]
78
+
79
+
80
+ def make_ocr_llm_pipeline_spec(
81
+ mode: OCRLLMPipelineMode,
82
+ *,
83
+ ocr_adapter_name: str | None = None,
84
+ llm_adapter_name: str,
85
+ name: str | None = None,
86
+ description: str = "",
87
+ ocr_step_id: str = "ocr",
88
+ llm_step_id: str = "llm",
89
+ ) -> PipelineSpec:
90
+ """Construit la ``PipelineSpec`` correspondant à un mode OCR+LLM.
91
+
92
+ Parameters
93
+ ----------
94
+ mode:
95
+ ``"text_only"`` (OCR → LLM texte) | ``"text_and_image"`` (OCR
96
+ → LLM texte+image) | ``"zero_shot"`` (VLM image → texte).
97
+ ocr_adapter_name:
98
+ Nom de l'adapter OCR amont (ex. ``"tesseract"``,
99
+ ``"precomputed"``). **Requis** pour ``text_only`` et
100
+ ``text_and_image`` ; **interdit** pour ``zero_shot``.
101
+ llm_adapter_name:
102
+ Nom de l'adapter LLM ou VLM (ex. ``"openai:gpt-4o"``,
103
+ ``"anthropic:claude-3-5-sonnet"``). Pour ``zero_shot``,
104
+ doit pointer sur un VLM adapter.
105
+ name:
106
+ Nom court de la pipeline (snake_case). Auto-généré depuis
107
+ ``mode`` + adapters si non fourni.
108
+ description:
109
+ Phrase courte pour le rapport. Vide par défaut.
110
+ ocr_step_id, llm_step_id:
111
+ Identifiants des étapes (utiles pour les ``inputs_from``
112
+ cross-pipeline). Défauts : ``"ocr"`` et ``"llm"``.
113
+
114
+ Returns
115
+ -------
116
+ PipelineSpec
117
+ Spec immutable prête à être exécutée par ``PipelineExecutor``.
118
+
119
+ Raises
120
+ ------
121
+ PicaronesError
122
+ Si la combinaison mode/adapters est incohérente
123
+ (ex. ``zero_shot`` avec ``ocr_adapter_name`` fourni).
124
+ """
125
+ if mode == "zero_shot":
126
+ if ocr_adapter_name is not None:
127
+ raise PicaronesError(
128
+ "mode 'zero_shot' incompatible avec ocr_adapter_name : "
129
+ "le VLM consomme directement l'image, pas d'OCR amont."
130
+ )
131
+ return _make_zero_shot_spec(
132
+ llm_adapter_name=llm_adapter_name,
133
+ name=name or f"vlm_zero_shot_{_safe_name(llm_adapter_name)}",
134
+ description=description,
135
+ llm_step_id=llm_step_id,
136
+ )
137
+
138
+ if mode not in ("text_only", "text_and_image"):
139
+ raise PicaronesError(
140
+ f"mode OCR+LLM inconnu : {mode!r}. "
141
+ "Attendu : text_only | text_and_image | zero_shot."
142
+ )
143
+
144
+ if not ocr_adapter_name:
145
+ raise PicaronesError(
146
+ f"mode {mode!r} requiert ocr_adapter_name (un adapter "
147
+ "produisant RAW_TEXT en amont du LLM)."
148
+ )
149
+
150
+ return _make_ocr_plus_llm_spec(
151
+ mode=mode,
152
+ ocr_adapter_name=ocr_adapter_name,
153
+ llm_adapter_name=llm_adapter_name,
154
+ name=name or (
155
+ f"ocr_llm_{mode}_"
156
+ f"{_safe_name(ocr_adapter_name)}_to_{_safe_name(llm_adapter_name)}"
157
+ ),
158
+ description=description,
159
+ ocr_step_id=ocr_step_id,
160
+ llm_step_id=llm_step_id,
161
+ )
162
+
163
+
164
+ def _make_zero_shot_spec(
165
+ *,
166
+ llm_adapter_name: str,
167
+ name: str,
168
+ description: str,
169
+ llm_step_id: str,
170
+ ) -> PipelineSpec:
171
+ """Spec ``zero_shot`` : un seul step VLM IMAGE → RAW_TEXT."""
172
+ return PipelineSpec(
173
+ name=name,
174
+ description=description,
175
+ initial_inputs=(ArtifactType.IMAGE,),
176
+ steps=(
177
+ PipelineStep(
178
+ id=llm_step_id,
179
+ kind="zero_shot_transcription",
180
+ adapter_name=llm_adapter_name,
181
+ input_types=(ArtifactType.IMAGE,),
182
+ output_types=(ArtifactType.RAW_TEXT,),
183
+ inputs_from={ArtifactType.IMAGE: INITIAL_STEP_ID},
184
+ ),
185
+ ),
186
+ )
187
+
188
+
189
+ def _make_ocr_plus_llm_spec(
190
+ *,
191
+ mode: str,
192
+ ocr_adapter_name: str,
193
+ llm_adapter_name: str,
194
+ name: str,
195
+ description: str,
196
+ ocr_step_id: str,
197
+ llm_step_id: str,
198
+ ) -> PipelineSpec:
199
+ """Spec à 2 steps : OCR (IMAGE → RAW_TEXT) + LLM (RAW_TEXT → CORRECTED_TEXT)."""
200
+ llm_inputs_from: dict[ArtifactType, str] = {
201
+ ArtifactType.RAW_TEXT: ocr_step_id,
202
+ }
203
+ llm_input_types: list[ArtifactType] = [ArtifactType.RAW_TEXT]
204
+ if mode == "text_and_image":
205
+ # Le LLM voit aussi l'image initiale (mode multimodal).
206
+ llm_inputs_from[ArtifactType.IMAGE] = INITIAL_STEP_ID
207
+ llm_input_types.append(ArtifactType.IMAGE)
208
+
209
+ return PipelineSpec(
210
+ name=name,
211
+ description=description,
212
+ initial_inputs=(ArtifactType.IMAGE,),
213
+ steps=(
214
+ PipelineStep(
215
+ id=ocr_step_id,
216
+ kind="ocr",
217
+ adapter_name=ocr_adapter_name,
218
+ input_types=(ArtifactType.IMAGE,),
219
+ output_types=(ArtifactType.RAW_TEXT,),
220
+ inputs_from={ArtifactType.IMAGE: INITIAL_STEP_ID},
221
+ ),
222
+ PipelineStep(
223
+ id=llm_step_id,
224
+ kind="post_correction",
225
+ adapter_name=llm_adapter_name,
226
+ input_types=tuple(llm_input_types),
227
+ output_types=(ArtifactType.CORRECTED_TEXT,),
228
+ inputs_from=llm_inputs_from,
229
+ ),
230
+ ),
231
+ )
232
+
233
+
234
+ def _safe_name(adapter_name: str) -> str:
235
+ """Convertit un ``adapter_name`` (qui peut contenir ``:``, ``/``,
236
+ etc.) en suffixe ``snake_case`` valide pour un step id."""
237
+ return (
238
+ adapter_name
239
+ .replace(":", "_")
240
+ .replace("/", "_")
241
+ .replace("-", "_")
242
+ .replace(".", "_")
243
+ .lower()
244
+ )
245
+
246
+
247
+ __all__ = [
248
+ "OCRLLMPipelineMode",
249
+ "make_ocr_llm_pipeline_spec",
250
+ ]
tests/pipeline/test_phase6_volet2_llm_pipeline_builder.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 6 volet 2 — ``make_ocr_llm_pipeline_spec``.
2
+
3
+ Vérifie que les 3 modes historiques de
4
+ ``picarones.pipelines.base.OCRLLMPipeline`` (text_only,
5
+ text_and_image, zero_shot) se traduisent en ``PipelineSpec``
6
+ canoniques exécutables par ``PipelineExecutor``.
7
+
8
+ Ces tests valident la **structure** de la spec produite ; ils ne
9
+ lancent pas de vraie exécution OCR/LLM (les adapters concrets sont
10
+ testés ailleurs). Le smoke test d'exécution end-to-end passe par
11
+ le runner de fixtures et vit dans
12
+ ``tests/integration/test_pipeline_executor_smoke.py`` (S8 / S9).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import pytest
18
+
19
+ from picarones.domain import ArtifactType, PicaronesError
20
+ from picarones.domain.pipeline_spec import INITIAL_STEP_ID
21
+ from picarones.pipeline.llm_pipeline_builder import make_ocr_llm_pipeline_spec
22
+ from picarones.pipeline.validation import validate_spec
23
+
24
+
25
+ # ──────────────────────────────────────────────────────────────────────
26
+ # Mode text_only — OCR + LLM (texte seul)
27
+ # ──────────────────────────────────────────────────────────────────────
28
+
29
+
30
+ class TestTextOnlyMode:
31
+ def test_two_steps_ocr_then_llm(self) -> None:
32
+ spec = make_ocr_llm_pipeline_spec(
33
+ mode="text_only",
34
+ ocr_adapter_name="tesseract",
35
+ llm_adapter_name="openai:gpt-4o",
36
+ )
37
+ assert len(spec.steps) == 2
38
+ assert spec.steps[0].kind == "ocr"
39
+ assert spec.steps[0].adapter_name == "tesseract"
40
+ assert spec.steps[1].kind == "post_correction"
41
+ assert spec.steps[1].adapter_name == "openai:gpt-4o"
42
+
43
+ def test_initial_input_is_image(self) -> None:
44
+ spec = make_ocr_llm_pipeline_spec(
45
+ mode="text_only",
46
+ ocr_adapter_name="tesseract",
47
+ llm_adapter_name="openai:gpt-4o",
48
+ )
49
+ assert spec.initial_inputs == (ArtifactType.IMAGE,)
50
+
51
+ def test_ocr_consumes_image_produces_raw_text(self) -> None:
52
+ spec = make_ocr_llm_pipeline_spec(
53
+ mode="text_only",
54
+ ocr_adapter_name="tesseract",
55
+ llm_adapter_name="mistral:large",
56
+ )
57
+ ocr = spec.steps[0]
58
+ assert ArtifactType.IMAGE in ocr.input_types
59
+ assert ArtifactType.RAW_TEXT in ocr.output_types
60
+ assert ocr.inputs_from[ArtifactType.IMAGE] == INITIAL_STEP_ID
61
+
62
+ def test_llm_reads_text_from_ocr_step(self) -> None:
63
+ spec = make_ocr_llm_pipeline_spec(
64
+ mode="text_only",
65
+ ocr_adapter_name="tesseract",
66
+ llm_adapter_name="mistral:large",
67
+ )
68
+ llm = spec.steps[1]
69
+ assert ArtifactType.RAW_TEXT in llm.input_types
70
+ # Crucial : le LLM tire son RAW_TEXT du step OCR (et non des
71
+ # initial inputs) — c'est la chaîne de production.
72
+ assert llm.inputs_from[ArtifactType.RAW_TEXT] == "ocr"
73
+
74
+ def test_llm_produces_corrected_text(self) -> None:
75
+ spec = make_ocr_llm_pipeline_spec(
76
+ mode="text_only",
77
+ ocr_adapter_name="tesseract",
78
+ llm_adapter_name="anthropic:claude-3-5-sonnet",
79
+ )
80
+ llm = spec.steps[1]
81
+ assert ArtifactType.CORRECTED_TEXT in llm.output_types
82
+
83
+ def test_llm_does_not_see_image_in_text_only(self) -> None:
84
+ """En mode text_only, le LLM ne consomme pas d'IMAGE."""
85
+ spec = make_ocr_llm_pipeline_spec(
86
+ mode="text_only",
87
+ ocr_adapter_name="tesseract",
88
+ llm_adapter_name="ollama:llama3",
89
+ )
90
+ llm = spec.steps[1]
91
+ assert ArtifactType.IMAGE not in llm.input_types
92
+ assert ArtifactType.IMAGE not in llm.inputs_from
93
+
94
+
95
+ # ──────────────────────────────────────────────────────────────────────
96
+ # Mode text_and_image — OCR + LLM multimodal
97
+ # ──────────────────────────────────────────────────────────────────────
98
+
99
+
100
+ class TestTextAndImageMode:
101
+ def test_two_steps_like_text_only(self) -> None:
102
+ spec = make_ocr_llm_pipeline_spec(
103
+ mode="text_and_image",
104
+ ocr_adapter_name="tesseract",
105
+ llm_adapter_name="openai:gpt-4o",
106
+ )
107
+ assert len(spec.steps) == 2
108
+
109
+ def test_llm_consumes_both_text_and_image(self) -> None:
110
+ spec = make_ocr_llm_pipeline_spec(
111
+ mode="text_and_image",
112
+ ocr_adapter_name="tesseract",
113
+ llm_adapter_name="openai:gpt-4o",
114
+ )
115
+ llm = spec.steps[1]
116
+ assert ArtifactType.RAW_TEXT in llm.input_types
117
+ assert ArtifactType.IMAGE in llm.input_types
118
+ # Le RAW_TEXT vient de l'OCR, l'IMAGE vient des inputs initiaux.
119
+ assert llm.inputs_from[ArtifactType.RAW_TEXT] == "ocr"
120
+ assert llm.inputs_from[ArtifactType.IMAGE] == INITIAL_STEP_ID
121
+
122
+ def test_llm_still_produces_corrected_text(self) -> None:
123
+ spec = make_ocr_llm_pipeline_spec(
124
+ mode="text_and_image",
125
+ ocr_adapter_name="precomputed",
126
+ llm_adapter_name="mistral:large",
127
+ )
128
+ assert ArtifactType.CORRECTED_TEXT in spec.steps[1].output_types
129
+
130
+
131
+ # ──────────────────────────────────────────────────────────────────────
132
+ # Mode zero_shot — VLM seul (pas d'OCR amont)
133
+ # ──────────────────────────────────────────────────────────────────────
134
+
135
+
136
+ class TestZeroShotMode:
137
+ def test_single_step(self) -> None:
138
+ spec = make_ocr_llm_pipeline_spec(
139
+ mode="zero_shot",
140
+ llm_adapter_name="anthropic:claude-3-5-sonnet",
141
+ )
142
+ assert len(spec.steps) == 1
143
+
144
+ def test_vlm_consumes_image_directly(self) -> None:
145
+ spec = make_ocr_llm_pipeline_spec(
146
+ mode="zero_shot",
147
+ llm_adapter_name="openai:gpt-4o",
148
+ )
149
+ vlm = spec.steps[0]
150
+ assert ArtifactType.IMAGE in vlm.input_types
151
+ assert vlm.inputs_from[ArtifactType.IMAGE] == INITIAL_STEP_ID
152
+
153
+ def test_vlm_produces_raw_text_not_corrected(self) -> None:
154
+ """En zero_shot, le VLM transcrit — il produit RAW_TEXT
155
+ (transcription primaire) et non CORRECTED_TEXT (qui implique
156
+ la correction d'un texte préexistant)."""
157
+ spec = make_ocr_llm_pipeline_spec(
158
+ mode="zero_shot",
159
+ llm_adapter_name="anthropic:claude-3-5-sonnet",
160
+ )
161
+ vlm = spec.steps[0]
162
+ assert ArtifactType.RAW_TEXT in vlm.output_types
163
+ assert ArtifactType.CORRECTED_TEXT not in vlm.output_types
164
+
165
+ def test_kind_is_zero_shot_transcription(self) -> None:
166
+ spec = make_ocr_llm_pipeline_spec(
167
+ mode="zero_shot",
168
+ llm_adapter_name="mistral:pixtral",
169
+ )
170
+ assert spec.steps[0].kind == "zero_shot_transcription"
171
+
172
+ def test_zero_shot_rejects_ocr_adapter(self) -> None:
173
+ """Combinaison incohérente : on ne fournit pas d'OCR amont
174
+ en zero-shot — le VLM consomme directement l'image."""
175
+ with pytest.raises(PicaronesError, match="zero_shot.*incompatible"):
176
+ make_ocr_llm_pipeline_spec(
177
+ mode="zero_shot",
178
+ ocr_adapter_name="tesseract",
179
+ llm_adapter_name="anthropic:claude-3-5-sonnet",
180
+ )
181
+
182
+
183
+ # ──────────────────────────────────────────────────────────────────────
184
+ # Validation — les specs produites passent ``validate_spec``
185
+ # ──────────────────────────────────────────────────────────────────────
186
+
187
+
188
+ class TestSpecsArevalid:
189
+ @pytest.mark.parametrize(
190
+ "mode,ocr_name",
191
+ [
192
+ ("text_only", "tesseract"),
193
+ ("text_and_image", "tesseract"),
194
+ ("zero_shot", None),
195
+ ],
196
+ )
197
+ def test_spec_passes_validation(self, mode: str, ocr_name: str | None) -> None:
198
+ """Les 3 modes produisent une spec valide ``validate_spec``."""
199
+ spec = make_ocr_llm_pipeline_spec(
200
+ mode=mode,
201
+ ocr_adapter_name=ocr_name,
202
+ llm_adapter_name="openai:gpt-4o",
203
+ )
204
+ # Passer des adapters fictifs disponibles — on teste juste
205
+ # la structure du DAG, pas la résolution runtime.
206
+ validate_spec(
207
+ spec,
208
+ available_adapters={"tesseract", "openai:gpt-4o"},
209
+ )
210
+
211
+
212
+ # ──────────────────────────────────────────────────────────────────────
213
+ # Erreurs — combinaisons invalides
214
+ # ──────────────────────────────────────────────────────────────────────
215
+
216
+
217
+ class TestErrorPaths:
218
+ def test_unknown_mode_raises(self) -> None:
219
+ with pytest.raises(PicaronesError, match="mode OCR.LLM inconnu"):
220
+ make_ocr_llm_pipeline_spec(
221
+ mode="invalid_mode", # type: ignore[arg-type]
222
+ ocr_adapter_name="tesseract",
223
+ llm_adapter_name="openai:gpt-4o",
224
+ )
225
+
226
+ def test_text_only_requires_ocr(self) -> None:
227
+ with pytest.raises(PicaronesError, match="requiert ocr_adapter_name"):
228
+ make_ocr_llm_pipeline_spec(
229
+ mode="text_only",
230
+ llm_adapter_name="openai:gpt-4o",
231
+ )
232
+
233
+ def test_text_and_image_requires_ocr(self) -> None:
234
+ with pytest.raises(PicaronesError, match="requiert ocr_adapter_name"):
235
+ make_ocr_llm_pipeline_spec(
236
+ mode="text_and_image",
237
+ llm_adapter_name="openai:gpt-4o",
238
+ )
239
+
240
+
241
+ # ──────────────────────────────────────────────────────────────────────
242
+ # Auto-naming
243
+ # ──────────────────────────────────────────────────────────────────────
244
+
245
+
246
+ class TestAutoNaming:
247
+ def test_auto_name_text_only(self) -> None:
248
+ spec = make_ocr_llm_pipeline_spec(
249
+ mode="text_only",
250
+ ocr_adapter_name="tesseract",
251
+ llm_adapter_name="openai:gpt-4o",
252
+ )
253
+ assert "text_only" in spec.name
254
+ assert "tesseract" in spec.name
255
+ # Les ``:`` du nom d'adapter LLM sont remplacés par ``_``.
256
+ assert ":" not in spec.name
257
+ assert "openai_gpt_4o" in spec.name
258
+
259
+ def test_explicit_name_overrides_auto(self) -> None:
260
+ spec = make_ocr_llm_pipeline_spec(
261
+ mode="text_only",
262
+ ocr_adapter_name="tesseract",
263
+ llm_adapter_name="openai:gpt-4o",
264
+ name="my_custom_pipeline",
265
+ )
266
+ assert spec.name == "my_custom_pipeline"
267
+
268
+ def test_auto_name_zero_shot(self) -> None:
269
+ spec = make_ocr_llm_pipeline_spec(
270
+ mode="zero_shot",
271
+ llm_adapter_name="anthropic:claude-3-5-sonnet",
272
+ )
273
+ assert spec.name.startswith("vlm_zero_shot_")
274
+ assert "claude_3_5_sonnet" in spec.name
275
+
276
+
277
+ # ──────────────────────────────────────────────────────────────────────
278
+ # YAML round-trip (réutilise l'infra Sprint S6)
279
+ # ──────────────────────────────────────────────────────────────────────
280
+
281
+
282
+ class TestYamlRoundtrip:
283
+ @pytest.mark.parametrize(
284
+ "mode,ocr_name",
285
+ [
286
+ ("text_only", "tesseract"),
287
+ ("text_and_image", "tesseract"),
288
+ ("zero_shot", None),
289
+ ],
290
+ )
291
+ def test_round_trip_through_yaml(self, mode: str, ocr_name: str | None) -> None:
292
+ """Une spec produite par le builder doit faire l'aller-retour
293
+ complet vers YAML sans perte d'information."""
294
+ from picarones.pipeline.yaml_io import (
295
+ dump_spec_to_yaml,
296
+ load_spec_from_yaml,
297
+ )
298
+
299
+ original = make_ocr_llm_pipeline_spec(
300
+ mode=mode,
301
+ ocr_adapter_name=ocr_name,
302
+ llm_adapter_name="openai:gpt-4o",
303
+ )
304
+ yaml_text = dump_spec_to_yaml(original)
305
+ reloaded = load_spec_from_yaml(yaml_text)
306
+ assert reloaded == original