Spaces:
Sleeping
feat(6-volet2): make_ocr_llm_pipeline_spec — convergence des 3 modes vers PipelineSpec
Browse filesPhase 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 +3 -3
- README.md +1 -1
- picarones/pipeline/__init__.py +7 -0
- picarones/pipeline/llm_pipeline_builder.py +250 -0
- tests/pipeline/test_phase6_volet2_llm_pipeline_builder.py +306 -0
|
@@ -123,7 +123,7 @@ picarones/
|
|
| 123 |
|
| 124 |
## État des tests et bugs historiques
|
| 125 |
|
| 126 |
-
`pytest tests/` → **
|
| 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` →
|
| 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` →
|
| 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).
|
|
@@ -394,7 +394,7 @@ ruff check picarones/ tests/
|
|
| 394 |
python -m mypy picarones/core/
|
| 395 |
```
|
| 396 |
|
| 397 |
-
**Test suite**: ~
|
| 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
|
|
@@ -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",
|
|
@@ -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 |
+
]
|
|
@@ -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
|