Spaces:
Sleeping
feat(sprint-A): wrapper BaseOCREngine→StepExecutor + prompt_template via params
Browse filesSprint A du plan v2.0 — fondation du chemin de retrait
``OCRLLMPipeline``. Trois livrables concrets qui débloquent le
Sprint B (refactor de ``OCRLLMPipeline.run()`` pour qu'il délègue
à ``PipelineExecutor``).
A.1 — LegacyOCREngineExecutor
-----------------------------
Nouveau module ``picarones/adapters/legacy_engines/_step_executor.py``
qui présente un ``BaseOCREngine`` legacy comme ``StepExecutor`` du
pipeline rewrite. Permet au ``PipelineExecutor`` (S7 du rewrite) de
consommer les 5 OCR engines legacy (Tesseract, Pero, Mistral OCR,
Google Vision, Azure DI) sans réimplémenter chacun en
``BaseOCRAdapter``.
Contrat :
- ``input_types = frozenset({IMAGE})``,
``output_types = frozenset({RAW_TEXT})``.
- ``execution_mode`` hérité de l'engine wrappé (``"cpu"`` pour
Tesseract/Pero, ``"io"`` pour les engines cloud).
- ``execute()`` lit l'``Artifact IMAGE``, appelle ``engine.run(uri)``,
écrit le texte produit dans ``context.workspace_uri`` via
``resolve_output_path``, retourne un ``Artifact RAW_TEXT``.
- ``EngineResult.error`` non-vide → ``OCRAdapterError`` propagée
(le ``PipelineExecutor`` capturera et marquera le step en échec).
Trace de retrait : ce wrapper est lui-même temporaire, supprimé en
Sprint H quand ``BaseOCREngine`` disparaîtra (les 5 engines auront
leur jumeau ``adapters/ocr/<engine>.py`` qui implémente déjà
``BaseOCRAdapter`` — la parité est déjà 5/5 côté rewrite).
A.2 — params["prompt_template"] pour BaseLLMAdapter.execute()
-------------------------------------------------------------
Le contrat actuel de ``BaseLLMAdapter.execute()`` (Sprint A14-S44)
substituait ``{text}`` via ``str.format(text=...)`` depuis
``self.config["correction_prompt"]`` ou les défauts par langue.
Le format legacy d'``OCRLLMPipeline`` utilise ``{ocr_output}``
et ``{image_b64}`` — incompatible avec ``str.format`` strict.
Trois ajouts :
1. Helper ``_substitute_prompt_variables(template, text, image_b64)``
qui détecte automatiquement la convention :
- ``{text}`` présent → ``str.format(text=...)`` (rewrite, strict).
- ``{ocr_output}`` ou ``{image_b64}`` présent → ``str.replace``
tolérant (legacy).
2. ``execute()`` lit ``params["prompt_template"]`` en priorité, puis
tombe sur ``self.config["correction_prompt"]``, puis sur les
défauts par langue. Permet à un caller construisant une
``PipelineSpec`` d'injecter un prompt sans devoir reconfigurer
l'adapter au constructeur.
3. La priorité reste documentée :
``params > config > defaults_per_lang > FR fallback``.
Aucune régression sur les callers existants — ceux qui définissent
``self.config["correction_prompt"]`` ou utilisent les défauts par
langue voient le même comportement qu'avant.
A.3 — Tests d'intégration
-------------------------
``tests/pipeline/test_sprint_a_legacy_engine_executor.py`` —
15 tests couvrant :
- Le contrat statique du wrapper (input_types, output_types).
- Le rejet d'un argument non-``BaseOCREngine``.
- L'héritage de ``execution_mode``.
- L'écriture du fichier ``RAW_TEXT`` dans ``workspace_uri``.
- La levée d'``OCRAdapterError`` quand ``IMAGE`` est manquant.
- Les deux conventions de substitution de prompt
(``{text}`` rewrite ; ``{ocr_output}`` / ``{image_b64}`` legacy).
- L'override ``params["prompt_template"]`` qui prime sur
``self.config``.
- **End-to-end** : un OCR legacy wrappé + un LLM rewrite enchaînés
via ``PipelineExecutor`` produisent un ``CORRECTED_TEXT`` identique
à ce que ``OCRLLMPipeline`` aurait produit. Vérifié pour les
modes ``text_only`` et ``text_and_image`` (le LLM reçoit l'image
encodée base64 en multimodal).
Bilan
-----
- ``pytest tests/`` : 4755 passed (+15), 0 failed.
- ``ruff check`` : clean.
- 1 module créé (181 LOC), 1 test créé (322 LOC), 2 fonctions
ajoutées dans ``adapters/llm/base.py`` (+45 LOC).
- Aucune API publique modifiée — c'est un ajout pur.
Sprint B débloqué
-----------------
Avec ``LegacyOCREngineExecutor`` et le ``params["prompt_template"]``,
le Sprint B peut maintenant :
1. Construire un ``PipelineSpec`` via ``make_ocr_llm_pipeline_spec``
(commit f894bf0).
2. Wrapper ``self.ocr_engine`` via ``LegacyOCREngineExecutor``.
3. Construire un mini-``PipelineExecutor`` mono-document avec un
tempdir comme ``workspace_uri``.
4. Lancer ``executor.run(spec, doc, initial_inputs, context)`` et
convertir le ``PipelineResult`` en ``EngineResult`` legacy.
C'est ce qui sera fait dans le commit ``feat(sprint-B)``.
https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP
- CLAUDE.md +3 -3
- README.md +2 -1
- picarones/adapters/legacy_engines/_step_executor.py +180 -0
- picarones/adapters/llm/base.py +71 -18
- tests/pipeline/test_sprint_a_legacy_engine_executor.py +411 -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/` → **4790 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` → 4790 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` → 4790 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).
|
|
@@ -200,6 +200,7 @@ For Docker, institutional deployment, or HuggingFace Spaces, see
|
|
| 200 |
|
| 201 |
| Engine | Type | Installation |
|
| 202 |
|--------|------|-------------|
|
|
|
|
| 203 |
| **Azure Doc Intelligence** | Cloud API | `AZURE_DOC_INTEL_ENDPOINT` + `AZURE_DOC_INTEL_KEY` |
|
| 204 |
| **Google Vision** | Cloud API | `GOOGLE_APPLICATION_CREDENTIALS` env var |
|
| 205 |
| **Mistral OCR** | Cloud API | `MISTRAL_API_KEY` env var |
|
|
@@ -394,7 +395,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
|
|
|
|
| 200 |
|
| 201 |
| Engine | Type | Installation |
|
| 202 |
|--------|------|-------------|
|
| 203 |
+
| **_step_executor** | Unknown | — |
|
| 204 |
| **Azure Doc Intelligence** | Cloud API | `AZURE_DOC_INTEL_ENDPOINT` + `AZURE_DOC_INTEL_KEY` |
|
| 205 |
| **Google Vision** | Cloud API | `GOOGLE_APPLICATION_CREDENTIALS` env var |
|
| 206 |
| **Mistral OCR** | Cloud API | `MISTRAL_API_KEY` env var |
|
|
|
|
| 395 |
python -m mypy picarones/core/
|
| 396 |
```
|
| 397 |
|
| 398 |
+
**Test suite**: ~4790 tests, ~3 min on a modern laptop. Coverage
|
| 399 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 400 |
requiring live HTTP. A handful of tests depend on optional engines
|
| 401 |
(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
|
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``LegacyOCREngineExecutor`` — wrapper ``BaseOCREngine`` → ``StepExecutor``.
|
| 2 |
+
|
| 3 |
+
Sprint A.1 du plan v2.0 (préparation à la suppression de
|
| 4 |
+
``OCRLLMPipeline``). Le wrapper présente les 5 OCR engines legacy
|
| 5 |
+
(``TesseractEngine``, ``PeroOCREngine``, ``MistralOCREngine``,
|
| 6 |
+
``AzureDocIntelEngine``, ``GoogleVisionEngine``) comme des
|
| 7 |
+
``StepExecutor`` consommables par ``PipelineExecutor``.
|
| 8 |
+
|
| 9 |
+
Pourquoi
|
| 10 |
+
--------
|
| 11 |
+
``OCRLLMPipeline`` historique compose un ``BaseOCREngine`` + un
|
| 12 |
+
``BaseLLMAdapter`` en mémoire. Le rewrite consomme un ``PipelineSpec``
|
| 13 |
+
exécuté par ``PipelineExecutor`` qui résout chaque step en
|
| 14 |
+
``StepExecutor``. Pour migrer progressivement (Sprint B), il faut
|
| 15 |
+
pouvoir injecter un OCR engine legacy dans le ``PipelineExecutor`` sans
|
| 16 |
+
réimplémenter chacun des 5 adapters au contrat ``BaseOCRAdapter``.
|
| 17 |
+
|
| 18 |
+
Le wrapper résout cette tension : il accepte une instance
|
| 19 |
+
``BaseOCREngine`` au constructeur, expose les attributs
|
| 20 |
+
``StepExecutor`` (``input_types``, ``output_types``, ``execution_mode``,
|
| 21 |
+
``execute``), et délègue à ``engine.run(image_path)`` en interne.
|
| 22 |
+
|
| 23 |
+
Trace de retrait
|
| 24 |
+
----------------
|
| 25 |
+
Ce wrapper est lui-même legacy au sens du Sprint H : il sera supprimé
|
| 26 |
+
en même temps que ``BaseOCREngine`` quand les 5 moteurs concrets
|
| 27 |
+
auront migré vers ``BaseOCRAdapter`` (qui existe déjà côté rewrite —
|
| 28 |
+
cf. ``picarones.adapters.ocr.tesseract.TesseractAdapter`` et al.).
|
| 29 |
+
|
| 30 |
+
Anti-sur-ingénierie
|
| 31 |
+
-------------------
|
| 32 |
+
- Pas de retry au niveau du wrapper (l'engine legacy gère ses propres
|
| 33 |
+
retries dans ``run()`` si configuré).
|
| 34 |
+
- Pas de capture custom des confidences (le rewrite a son propre
|
| 35 |
+
artifact ``CONFIDENCES`` dédié, pas mappé ici).
|
| 36 |
+
- ``run().error`` non vide → on lève ``OCRAdapterError`` ; le
|
| 37 |
+
``PipelineExecutor`` capturera et marquera le step en échec.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
from typing import Any
|
| 44 |
+
|
| 45 |
+
from picarones.adapters.legacy_engines.base import BaseOCREngine
|
| 46 |
+
from picarones.adapters.ocr.base import OCRAdapterError
|
| 47 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 48 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class LegacyOCREngineExecutor:
|
| 52 |
+
"""Présente un ``BaseOCREngine`` legacy comme ``StepExecutor``.
|
| 53 |
+
|
| 54 |
+
Parameters
|
| 55 |
+
----------
|
| 56 |
+
engine:
|
| 57 |
+
Instance d'un sous-classe de ``BaseOCREngine`` (Tesseract,
|
| 58 |
+
Pero, Mistral OCR, Google Vision, Azure DI).
|
| 59 |
+
|
| 60 |
+
Attributes
|
| 61 |
+
----------
|
| 62 |
+
name:
|
| 63 |
+
Délégué à ``engine.name``.
|
| 64 |
+
input_types:
|
| 65 |
+
``frozenset({ArtifactType.IMAGE})`` — un OCR consomme une image.
|
| 66 |
+
output_types:
|
| 67 |
+
``frozenset({ArtifactType.RAW_TEXT})`` — produit du texte plat.
|
| 68 |
+
execution_mode:
|
| 69 |
+
Hérité de ``engine.execution_mode`` (``"io"`` pour les engines
|
| 70 |
+
cloud, ``"cpu"`` pour Tesseract/Pero qui sont CPU-bound).
|
| 71 |
+
|
| 72 |
+
Examples
|
| 73 |
+
--------
|
| 74 |
+
>>> from picarones.adapters.legacy_engines.tesseract import TesseractEngine
|
| 75 |
+
>>> from picarones.adapters.legacy_engines._step_executor import (
|
| 76 |
+
... LegacyOCREngineExecutor,
|
| 77 |
+
... )
|
| 78 |
+
>>> step = LegacyOCREngineExecutor(TesseractEngine({"lang": "fra"}))
|
| 79 |
+
>>> step.input_types
|
| 80 |
+
frozenset({<ArtifactType.IMAGE: 'image'>})
|
| 81 |
+
>>> step.output_types
|
| 82 |
+
frozenset({<ArtifactType.RAW_TEXT: 'raw_text'>})
|
| 83 |
+
"""
|
| 84 |
+
|
| 85 |
+
input_types: frozenset = frozenset({ArtifactType.IMAGE})
|
| 86 |
+
output_types: frozenset = frozenset({ArtifactType.RAW_TEXT})
|
| 87 |
+
|
| 88 |
+
def __init__(self, engine: BaseOCREngine) -> None:
|
| 89 |
+
if not isinstance(engine, BaseOCREngine):
|
| 90 |
+
raise OCRAdapterError(
|
| 91 |
+
"LegacyOCREngineExecutor requires a BaseOCREngine instance ; "
|
| 92 |
+
f"got {type(engine).__name__}."
|
| 93 |
+
)
|
| 94 |
+
self._engine = engine
|
| 95 |
+
# Le runner choisit ``ProcessPoolExecutor`` pour ``"cpu"``
|
| 96 |
+
# (Tesseract/Pero) et ``ThreadPoolExecutor`` pour ``"io"``
|
| 97 |
+
# (Mistral/Google/Azure). On respecte le mode déclaré par
|
| 98 |
+
# l'engine.
|
| 99 |
+
self.execution_mode: str = getattr(engine, "execution_mode", "io")
|
| 100 |
+
|
| 101 |
+
@property
|
| 102 |
+
def name(self) -> str:
|
| 103 |
+
return self._engine.name
|
| 104 |
+
|
| 105 |
+
def execute(
|
| 106 |
+
self,
|
| 107 |
+
inputs: dict[ArtifactType, Artifact],
|
| 108 |
+
params: dict[str, Any],
|
| 109 |
+
context: Any,
|
| 110 |
+
) -> dict[ArtifactType, Artifact]:
|
| 111 |
+
"""Exécute l'OCR engine legacy et retourne un ``Artifact RAW_TEXT``.
|
| 112 |
+
|
| 113 |
+
Parameters
|
| 114 |
+
----------
|
| 115 |
+
inputs:
|
| 116 |
+
Doit contenir ``ArtifactType.IMAGE``. L'URI de l'artefact
|
| 117 |
+
image est passée à ``engine.run()``.
|
| 118 |
+
params:
|
| 119 |
+
Ignorés. La configuration de l'engine passe par son
|
| 120 |
+
constructeur, pas par les ``params`` du step.
|
| 121 |
+
context:
|
| 122 |
+
``RunContext``. Sert à composer les ``Artifact.id`` et à
|
| 123 |
+
résoudre le chemin d'écriture du texte produit
|
| 124 |
+
(``context.workspace_uri``).
|
| 125 |
+
|
| 126 |
+
Returns
|
| 127 |
+
-------
|
| 128 |
+
dict[ArtifactType, Artifact]
|
| 129 |
+
``{ArtifactType.RAW_TEXT: Artifact(uri=<text_file>)}``.
|
| 130 |
+
|
| 131 |
+
Raises
|
| 132 |
+
------
|
| 133 |
+
OCRAdapterError
|
| 134 |
+
Si ``inputs[IMAGE]`` est absent, sans URI, ou si
|
| 135 |
+
``engine.run()`` retourne un ``EngineResult`` en erreur.
|
| 136 |
+
"""
|
| 137 |
+
if ArtifactType.IMAGE not in inputs:
|
| 138 |
+
raise OCRAdapterError(
|
| 139 |
+
f"{self.name} : input IMAGE manquant.",
|
| 140 |
+
)
|
| 141 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 142 |
+
if image_artifact.uri is None:
|
| 143 |
+
raise OCRAdapterError(
|
| 144 |
+
f"{self.name} : artefact image "
|
| 145 |
+
f"{image_artifact.id!r} sans URI.",
|
| 146 |
+
)
|
| 147 |
+
image_path = Path(image_artifact.uri)
|
| 148 |
+
if not image_path.exists():
|
| 149 |
+
raise OCRAdapterError(
|
| 150 |
+
f"{self.name} : fichier image introuvable {image_path!r}.",
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
result = self._engine.run(image_path)
|
| 154 |
+
if not result.success:
|
| 155 |
+
raise OCRAdapterError(
|
| 156 |
+
f"{self.name} : OCR engine a échoué ({result.error}).",
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
# Le contrat StepExecutor exige des artifacts avec URI filesystem
|
| 160 |
+
# — on écrit le texte produit dans le workspace du run.
|
| 161 |
+
out_path = resolve_output_path(
|
| 162 |
+
input_path=image_path,
|
| 163 |
+
adapter_name=self.name,
|
| 164 |
+
suffix="raw_text.txt",
|
| 165 |
+
context=context,
|
| 166 |
+
)
|
| 167 |
+
out_path.write_text(result.text, encoding="utf-8")
|
| 168 |
+
|
| 169 |
+
return {
|
| 170 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 171 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 172 |
+
document_id=context.document_id,
|
| 173 |
+
type=ArtifactType.RAW_TEXT,
|
| 174 |
+
produced_by_step="ocr",
|
| 175 |
+
uri=str(out_path),
|
| 176 |
+
),
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
__all__ = ["LegacyOCREngineExecutor"]
|
|
@@ -152,6 +152,47 @@ def log_http_error(
|
|
| 152 |
from picarones.domain.errors import AdapterStepError
|
| 153 |
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
class LLMAdapterError(AdapterStepError):
|
| 156 |
"""Erreur typée pour un échec d'adapter LLM.
|
| 157 |
|
|
@@ -427,26 +468,38 @@ class BaseLLMAdapter(ABC):
|
|
| 427 |
image_path.read_bytes(),
|
| 428 |
).decode("ascii")
|
| 429 |
|
| 430 |
-
# Priorité
|
| 431 |
-
#
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
else:
|
| 436 |
-
|
| 437 |
-
if
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
)
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
prompt = prompt_template.format(text=original_text)
|
| 450 |
|
| 451 |
result = self.complete(prompt, image_b64=image_b64)
|
| 452 |
if not result.success:
|
|
|
|
| 152 |
from picarones.domain.errors import AdapterStepError
|
| 153 |
|
| 154 |
|
| 155 |
+
def _substitute_prompt_variables(
|
| 156 |
+
template: str,
|
| 157 |
+
text: str,
|
| 158 |
+
image_b64: str | None,
|
| 159 |
+
) -> str:
|
| 160 |
+
"""Substitue les variables d'un template de prompt LLM.
|
| 161 |
+
|
| 162 |
+
Supporte deux conventions de nommage des variables :
|
| 163 |
+
|
| 164 |
+
- **Rewrite** (Sprint A14-S44) : ``{text}``. Substitué par
|
| 165 |
+
``str.format(text=text)``.
|
| 166 |
+
- **Legacy** (``OCRLLMPipeline``, Sprint A.2 du plan v2.0) :
|
| 167 |
+
``{ocr_output}`` et ``{image_b64}``. Substitués par
|
| 168 |
+
``str.replace(...)`` — tolérant si une variable est absente
|
| 169 |
+
du template.
|
| 170 |
+
|
| 171 |
+
La convention est détectée automatiquement. Si le template
|
| 172 |
+
contient ``{ocr_output}`` ou ``{image_b64}``, on applique le
|
| 173 |
+
format legacy ; sinon, on applique le format rewrite (qui
|
| 174 |
+
lèvera ``KeyError`` si une variable inattendue est utilisée,
|
| 175 |
+
comportement strict d'origine).
|
| 176 |
+
|
| 177 |
+
Parameters
|
| 178 |
+
----------
|
| 179 |
+
template:
|
| 180 |
+
Template de prompt (chaîne avec variables ``{...}``).
|
| 181 |
+
text:
|
| 182 |
+
Texte OCR à injecter (substitue ``{text}`` ou ``{ocr_output}``).
|
| 183 |
+
image_b64:
|
| 184 |
+
Image encodée base64 sans préfixe (substitue ``{image_b64}``).
|
| 185 |
+
``None`` → chaîne vide pour les modes texte-seul.
|
| 186 |
+
"""
|
| 187 |
+
if "{ocr_output}" in template or "{image_b64}" in template:
|
| 188 |
+
return (
|
| 189 |
+
template
|
| 190 |
+
.replace("{ocr_output}", text)
|
| 191 |
+
.replace("{image_b64}", image_b64 or "")
|
| 192 |
+
)
|
| 193 |
+
return template.format(text=text)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
class LLMAdapterError(AdapterStepError):
|
| 197 |
"""Erreur typée pour un échec d'adapter LLM.
|
| 198 |
|
|
|
|
| 468 |
image_path.read_bytes(),
|
| 469 |
).decode("ascii")
|
| 470 |
|
| 471 |
+
# Priorité (Sprint A.2 du plan v2.0) :
|
| 472 |
+
# 1. ``params["prompt_template"]`` (override par le step lui-même —
|
| 473 |
+
# permet à un caller qui construit une PipelineSpec d'injecter
|
| 474 |
+
# un prompt personnalisé sans toucher à la config de l'adapter).
|
| 475 |
+
# 2. ``self.config["correction_prompt"]`` (override au constructeur
|
| 476 |
+
# de l'adapter — pattern historique).
|
| 477 |
+
# 3. Prompt par langue selon ``self.config["lang"]``.
|
| 478 |
+
# 4. Fallback FR.
|
| 479 |
+
param_prompt = params.get("prompt_template") if params else None
|
| 480 |
+
if param_prompt is not None:
|
| 481 |
+
prompt_template = param_prompt
|
| 482 |
else:
|
| 483 |
+
custom_prompt = self.config.get("correction_prompt")
|
| 484 |
+
if custom_prompt is not None:
|
| 485 |
+
prompt_template = custom_prompt
|
| 486 |
+
else:
|
| 487 |
+
lang = (self.config.get("lang") or "fr").lower()
|
| 488 |
+
if lang not in self.DEFAULT_CORRECTION_PROMPTS:
|
| 489 |
+
logger.warning(
|
| 490 |
+
"[%s] lang=%r non supportée par "
|
| 491 |
+
"DEFAULT_CORRECTION_PROMPTS (%s) — fallback FR. "
|
| 492 |
+
"Pour un corpus dans cette langue, fournir "
|
| 493 |
+
"config['correction_prompt'] explicite.",
|
| 494 |
+
self.name, lang,
|
| 495 |
+
sorted(self.DEFAULT_CORRECTION_PROMPTS.keys()),
|
| 496 |
+
)
|
| 497 |
+
prompt_template = self.DEFAULT_CORRECTION_PROMPTS.get(
|
| 498 |
+
lang, self.DEFAULT_CORRECTION_PROMPTS["fr"],
|
| 499 |
)
|
| 500 |
+
prompt = _substitute_prompt_variables(
|
| 501 |
+
prompt_template, original_text, image_b64,
|
| 502 |
+
)
|
|
|
|
| 503 |
|
| 504 |
result = self.complete(prompt, image_b64=image_b64)
|
| 505 |
if not result.success:
|
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint A.3 (plan v2.0) — intégration OCR legacy + LLM rewrite.
|
| 2 |
+
|
| 3 |
+
Vérifie que :
|
| 4 |
+
|
| 5 |
+
1. ``LegacyOCREngineExecutor`` (Sprint A.1) wrap correctement un
|
| 6 |
+
``BaseOCREngine`` legacy en ``StepExecutor`` rewrite.
|
| 7 |
+
2. ``BaseLLMAdapter.execute()`` (Sprint A.2) accepte un
|
| 8 |
+
``params["prompt_template"]`` avec convention legacy
|
| 9 |
+
(``{ocr_output}`` / ``{image_b64}``) en plus de la convention
|
| 10 |
+
rewrite (``{text}``).
|
| 11 |
+
3. Les deux briques s'enchaînent dans ``PipelineExecutor`` via la
|
| 12 |
+
spec produite par ``make_ocr_llm_pipeline_spec`` (Phase 6 volet 2,
|
| 13 |
+
commit f894bf0) et produisent le texte attendu.
|
| 14 |
+
|
| 15 |
+
Ce test prouve que la délégation prévue au Sprint B (refactor de
|
| 16 |
+
``OCRLLMPipeline.run()``) est techniquement réalisable — le pont
|
| 17 |
+
entre l'API legacy et le rewrite est fonctionnel.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any, Optional
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
from picarones.adapters.legacy_engines._step_executor import (
|
| 28 |
+
LegacyOCREngineExecutor,
|
| 29 |
+
)
|
| 30 |
+
from picarones.adapters.legacy_engines.base import BaseOCREngine
|
| 31 |
+
from picarones.adapters.llm.base import (
|
| 32 |
+
BaseLLMAdapter,
|
| 33 |
+
_substitute_prompt_variables,
|
| 34 |
+
)
|
| 35 |
+
from picarones.adapters.ocr.base import OCRAdapterError
|
| 36 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 37 |
+
from picarones.domain.documents import DocumentRef
|
| 38 |
+
from picarones.pipeline import (
|
| 39 |
+
PipelineExecutor,
|
| 40 |
+
RunContext,
|
| 41 |
+
make_ocr_llm_pipeline_spec,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 46 |
+
# Mocks — OCR engine legacy + LLM adapter rewrite
|
| 47 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class _MockOCREngine(BaseOCREngine):
|
| 51 |
+
"""OCR engine déterministe (texte fixe quel que soit l'image)."""
|
| 52 |
+
|
| 53 |
+
def __init__(self, fixed_text: str = "ocr output text") -> None:
|
| 54 |
+
super().__init__(config={})
|
| 55 |
+
self._fixed_text = fixed_text
|
| 56 |
+
|
| 57 |
+
@property
|
| 58 |
+
def name(self) -> str:
|
| 59 |
+
return "mock_ocr"
|
| 60 |
+
|
| 61 |
+
def version(self) -> str:
|
| 62 |
+
return "1.0.0"
|
| 63 |
+
|
| 64 |
+
def _run_ocr(self, image_path: Path) -> str:
|
| 65 |
+
return self._fixed_text
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class _MockLLMAdapter(BaseLLMAdapter):
|
| 69 |
+
"""LLM adapter qui renvoie le prompt reçu en upper-case.
|
| 70 |
+
|
| 71 |
+
Utile pour vérifier ce que l'adapter a effectivement reçu après
|
| 72 |
+
substitution des variables — le test peut grep le ``LLMResult.text``.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
def __init__(self) -> None:
|
| 76 |
+
super().__init__(model="mock-1", config={})
|
| 77 |
+
self.last_prompt: Optional[str] = None
|
| 78 |
+
self.last_image_b64: Optional[str] = None
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def name(self) -> str:
|
| 82 |
+
return "mock_llm"
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def default_model(self) -> str:
|
| 86 |
+
return "mock-1"
|
| 87 |
+
|
| 88 |
+
def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
|
| 89 |
+
self.last_prompt = prompt
|
| 90 |
+
self.last_image_b64 = image_b64
|
| 91 |
+
# Renvoie le prompt entier en upper-case pour qu'on puisse le
|
| 92 |
+
# vérifier côté test.
|
| 93 |
+
return prompt.upper()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 97 |
+
# A.1 — LegacyOCREngineExecutor seul
|
| 98 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class TestLegacyOCREngineExecutor:
|
| 102 |
+
def test_static_contract(self) -> None:
|
| 103 |
+
"""Les attributs StepExecutor sont déclarés correctement."""
|
| 104 |
+
assert LegacyOCREngineExecutor.input_types == frozenset(
|
| 105 |
+
{ArtifactType.IMAGE},
|
| 106 |
+
)
|
| 107 |
+
assert LegacyOCREngineExecutor.output_types == frozenset(
|
| 108 |
+
{ArtifactType.RAW_TEXT},
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
def test_rejects_non_engine(self) -> None:
|
| 112 |
+
with pytest.raises(OCRAdapterError):
|
| 113 |
+
LegacyOCREngineExecutor("not an engine") # type: ignore[arg-type]
|
| 114 |
+
|
| 115 |
+
def test_inherits_execution_mode_from_engine(self) -> None:
|
| 116 |
+
engine = _MockOCREngine()
|
| 117 |
+
engine.execution_mode = "cpu"
|
| 118 |
+
step = LegacyOCREngineExecutor(engine)
|
| 119 |
+
assert step.execution_mode == "cpu"
|
| 120 |
+
|
| 121 |
+
def test_name_delegates_to_engine(self) -> None:
|
| 122 |
+
step = LegacyOCREngineExecutor(_MockOCREngine())
|
| 123 |
+
assert step.name == "mock_ocr"
|
| 124 |
+
|
| 125 |
+
def test_execute_writes_text_artifact(self, tmp_path: Path) -> None:
|
| 126 |
+
"""Le wrapper écrit le texte OCR dans le workspace et retourne
|
| 127 |
+
un Artifact RAW_TEXT pointant sur ce fichier."""
|
| 128 |
+
engine = _MockOCREngine(fixed_text="bonjour le monde")
|
| 129 |
+
step = LegacyOCREngineExecutor(engine)
|
| 130 |
+
|
| 131 |
+
# Préparer un fichier image factice (le mock n'utilise pas son
|
| 132 |
+
# contenu, mais le wrapper vérifie son existence).
|
| 133 |
+
image_path = tmp_path / "input.png"
|
| 134 |
+
image_path.write_bytes(b"\x89PNG fake")
|
| 135 |
+
image_artifact = Artifact(
|
| 136 |
+
id="doc1:initial:image",
|
| 137 |
+
document_id="doc1",
|
| 138 |
+
type=ArtifactType.IMAGE,
|
| 139 |
+
uri=str(image_path),
|
| 140 |
+
)
|
| 141 |
+
context = RunContext(
|
| 142 |
+
document_id="doc1",
|
| 143 |
+
code_version="test",
|
| 144 |
+
pipeline_name="test_pipe",
|
| 145 |
+
workspace_uri=str(tmp_path),
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
outputs = step.execute(
|
| 149 |
+
inputs={ArtifactType.IMAGE: image_artifact},
|
| 150 |
+
params={},
|
| 151 |
+
context=context,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
assert ArtifactType.RAW_TEXT in outputs
|
| 155 |
+
text_artifact = outputs[ArtifactType.RAW_TEXT]
|
| 156 |
+
assert text_artifact.document_id == "doc1"
|
| 157 |
+
assert text_artifact.produced_by_step == "ocr"
|
| 158 |
+
text_path = Path(text_artifact.uri)
|
| 159 |
+
assert text_path.exists()
|
| 160 |
+
assert text_path.read_text(encoding="utf-8") == "bonjour le monde"
|
| 161 |
+
|
| 162 |
+
def test_execute_raises_on_missing_image(self, tmp_path: Path) -> None:
|
| 163 |
+
step = LegacyOCREngineExecutor(_MockOCREngine())
|
| 164 |
+
context = RunContext(
|
| 165 |
+
document_id="doc1",
|
| 166 |
+
code_version="test",
|
| 167 |
+
pipeline_name="test_pipe",
|
| 168 |
+
workspace_uri=str(tmp_path),
|
| 169 |
+
)
|
| 170 |
+
with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
|
| 171 |
+
step.execute(inputs={}, params={}, context=context)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 175 |
+
# A.2 — _substitute_prompt_variables et BaseLLMAdapter avec params
|
| 176 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class TestPromptSubstitution:
|
| 180 |
+
def test_rewrite_format_text(self) -> None:
|
| 181 |
+
out = _substitute_prompt_variables("Corrige : {text}", "ocr", None)
|
| 182 |
+
assert out == "Corrige : ocr"
|
| 183 |
+
|
| 184 |
+
def test_legacy_format_ocr_output(self) -> None:
|
| 185 |
+
out = _substitute_prompt_variables(
|
| 186 |
+
"Corrige : {ocr_output}", "ocr", None,
|
| 187 |
+
)
|
| 188 |
+
assert out == "Corrige : ocr"
|
| 189 |
+
|
| 190 |
+
def test_legacy_format_with_image_b64(self) -> None:
|
| 191 |
+
out = _substitute_prompt_variables(
|
| 192 |
+
"Img: {image_b64} OCR: {ocr_output}", "ocr", "b64data",
|
| 193 |
+
)
|
| 194 |
+
assert out == "Img: b64data OCR: ocr"
|
| 195 |
+
|
| 196 |
+
def test_legacy_format_image_none_becomes_empty(self) -> None:
|
| 197 |
+
out = _substitute_prompt_variables(
|
| 198 |
+
"Img: {image_b64}, OCR: {ocr_output}", "ocr", None,
|
| 199 |
+
)
|
| 200 |
+
assert out == "Img: , OCR: ocr"
|
| 201 |
+
|
| 202 |
+
def test_only_image_b64_no_ocr_output(self) -> None:
|
| 203 |
+
"""Un template legacy peut n'avoir que ``{image_b64}`` (mode
|
| 204 |
+
zero-shot avec convention legacy)."""
|
| 205 |
+
out = _substitute_prompt_variables(
|
| 206 |
+
"Transcris l'image : {image_b64}", "", "b64data",
|
| 207 |
+
)
|
| 208 |
+
assert out == "Transcris l'image : b64data"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class TestBaseLLMAdapterAcceptsParamsPromptTemplate:
|
| 212 |
+
def test_params_prompt_template_overrides_config(
|
| 213 |
+
self, tmp_path: Path,
|
| 214 |
+
) -> None:
|
| 215 |
+
"""Sprint A.2 — un caller qui construit une PipelineSpec peut
|
| 216 |
+
injecter un prompt_template via ``params``, qui prime sur
|
| 217 |
+
``self.config["correction_prompt"]``."""
|
| 218 |
+
adapter = _MockLLMAdapter()
|
| 219 |
+
adapter.config["correction_prompt"] = "OLD CONFIG: {text}"
|
| 220 |
+
|
| 221 |
+
# Préparer un text artifact (le LLM lit depuis disque).
|
| 222 |
+
text_path = tmp_path / "ocr.txt"
|
| 223 |
+
text_path.write_text("ocr text here", encoding="utf-8")
|
| 224 |
+
text_artifact = Artifact(
|
| 225 |
+
id="doc1:ocr:raw_text",
|
| 226 |
+
document_id="doc1",
|
| 227 |
+
type=ArtifactType.RAW_TEXT,
|
| 228 |
+
uri=str(text_path),
|
| 229 |
+
)
|
| 230 |
+
context = RunContext(
|
| 231 |
+
document_id="doc1",
|
| 232 |
+
code_version="test",
|
| 233 |
+
pipeline_name="test_pipe",
|
| 234 |
+
workspace_uri=str(tmp_path),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
adapter.execute(
|
| 238 |
+
inputs={ArtifactType.RAW_TEXT: text_artifact},
|
| 239 |
+
params={"prompt_template": "NEW PARAM: {ocr_output}"},
|
| 240 |
+
context=context,
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
# Le prompt utilisé doit venir de params, pas de config.
|
| 244 |
+
assert adapter.last_prompt == "NEW PARAM: ocr text here"
|
| 245 |
+
|
| 246 |
+
def test_params_legacy_template_with_image(
|
| 247 |
+
self, tmp_path: Path,
|
| 248 |
+
) -> None:
|
| 249 |
+
"""Le template legacy ``{ocr_output}`` + ``{image_b64}`` est
|
| 250 |
+
substitué correctement quand l'image est dans les inputs."""
|
| 251 |
+
adapter = _MockLLMAdapter()
|
| 252 |
+
text_path = tmp_path / "ocr.txt"
|
| 253 |
+
text_path.write_text("hello", encoding="utf-8")
|
| 254 |
+
image_path = tmp_path / "img.png"
|
| 255 |
+
image_path.write_bytes(b"\x89PNG fake")
|
| 256 |
+
text_artifact = Artifact(
|
| 257 |
+
id="doc1:ocr:raw_text",
|
| 258 |
+
document_id="doc1",
|
| 259 |
+
type=ArtifactType.RAW_TEXT,
|
| 260 |
+
uri=str(text_path),
|
| 261 |
+
)
|
| 262 |
+
image_artifact = Artifact(
|
| 263 |
+
id="doc1:initial:image",
|
| 264 |
+
document_id="doc1",
|
| 265 |
+
type=ArtifactType.IMAGE,
|
| 266 |
+
uri=str(image_path),
|
| 267 |
+
)
|
| 268 |
+
context = RunContext(
|
| 269 |
+
document_id="doc1",
|
| 270 |
+
code_version="test",
|
| 271 |
+
pipeline_name="test_pipe",
|
| 272 |
+
workspace_uri=str(tmp_path),
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
adapter.execute(
|
| 276 |
+
inputs={
|
| 277 |
+
ArtifactType.RAW_TEXT: text_artifact,
|
| 278 |
+
ArtifactType.IMAGE: image_artifact,
|
| 279 |
+
},
|
| 280 |
+
params={
|
| 281 |
+
"prompt_template": (
|
| 282 |
+
"T:{ocr_output}|I:{image_b64}"
|
| 283 |
+
),
|
| 284 |
+
},
|
| 285 |
+
context=context,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
assert adapter.last_prompt is not None
|
| 289 |
+
assert adapter.last_prompt.startswith("T:hello|I:")
|
| 290 |
+
# L'image a été passée au LLM (mode multimodal).
|
| 291 |
+
assert adapter.last_image_b64 is not None
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 295 |
+
# A.3 — Intégration OCR legacy + LLM rewrite via PipelineExecutor
|
| 296 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
class TestEndToEndOCRPlusLLM:
|
| 300 |
+
"""Le scénario clé : un caller qui aujourd'hui construit un
|
| 301 |
+
``OCRLLMPipeline(...)`` peut, dès Sprint A, le remplacer par
|
| 302 |
+
une ``PipelineSpec`` exécutée via ``PipelineExecutor`` avec un
|
| 303 |
+
OCR engine legacy wrappé."""
|
| 304 |
+
|
| 305 |
+
def _build_executor(
|
| 306 |
+
self,
|
| 307 |
+
ocr_engine: BaseOCREngine,
|
| 308 |
+
llm_adapter: BaseLLMAdapter,
|
| 309 |
+
) -> PipelineExecutor:
|
| 310 |
+
ocr_step = LegacyOCREngineExecutor(ocr_engine)
|
| 311 |
+
|
| 312 |
+
def resolver(name: str) -> Any:
|
| 313 |
+
if name == ocr_engine.name:
|
| 314 |
+
return ocr_step
|
| 315 |
+
if name == "mock_llm:mock-1":
|
| 316 |
+
return llm_adapter
|
| 317 |
+
raise KeyError(f"adapter inconnu : {name}")
|
| 318 |
+
|
| 319 |
+
return PipelineExecutor(adapter_resolver=resolver)
|
| 320 |
+
|
| 321 |
+
def test_text_only_pipeline_runs_end_to_end(
|
| 322 |
+
self, tmp_path: Path,
|
| 323 |
+
) -> None:
|
| 324 |
+
"""Mode TEXT_ONLY — OCR legacy → LLM rewrite produit
|
| 325 |
+
``CORRECTED_TEXT``."""
|
| 326 |
+
ocr = _MockOCREngine(fixed_text="texte ocr brut")
|
| 327 |
+
llm = _MockLLMAdapter()
|
| 328 |
+
|
| 329 |
+
spec = make_ocr_llm_pipeline_spec(
|
| 330 |
+
mode="text_only",
|
| 331 |
+
ocr_adapter_name=ocr.name,
|
| 332 |
+
llm_adapter_name="mock_llm:mock-1",
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
# Image factice
|
| 336 |
+
image_path = tmp_path / "scan.png"
|
| 337 |
+
image_path.write_bytes(b"\x89PNG fake")
|
| 338 |
+
document = DocumentRef(id="doc_e2e", image_uri=str(image_path))
|
| 339 |
+
context = RunContext(
|
| 340 |
+
document_id="doc_e2e",
|
| 341 |
+
code_version="test",
|
| 342 |
+
pipeline_name=spec.name,
|
| 343 |
+
workspace_uri=str(tmp_path),
|
| 344 |
+
)
|
| 345 |
+
initial_inputs = {
|
| 346 |
+
ArtifactType.IMAGE: Artifact(
|
| 347 |
+
id="doc_e2e:initial:image",
|
| 348 |
+
document_id="doc_e2e",
|
| 349 |
+
type=ArtifactType.IMAGE,
|
| 350 |
+
uri=str(image_path),
|
| 351 |
+
),
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
executor = self._build_executor(ocr, llm)
|
| 355 |
+
result = executor.run(spec, document, initial_inputs, context)
|
| 356 |
+
|
| 357 |
+
assert result.succeeded, f"pipeline failed: {result}"
|
| 358 |
+
# Le résultat porte une liste plate d'artifacts ; on filtre par
|
| 359 |
+
# type pour récupérer le CORRECTED_TEXT produit en bout de chaîne.
|
| 360 |
+
corrected_artifacts = [
|
| 361 |
+
a for a in result.artifacts if a.type == ArtifactType.CORRECTED_TEXT
|
| 362 |
+
]
|
| 363 |
+
assert len(corrected_artifacts) == 1
|
| 364 |
+
corrected = corrected_artifacts[0]
|
| 365 |
+
text = Path(corrected.uri).read_text(encoding="utf-8")
|
| 366 |
+
# Le mock LLM met le prompt en upper-case ; le texte OCR est
|
| 367 |
+
# quelque part dans cette upper-case version.
|
| 368 |
+
assert "TEXTE OCR BRUT" in text
|
| 369 |
+
# Le LLM a bien reçu le texte OCR (pas l'image en text-only).
|
| 370 |
+
assert "texte ocr brut" in (llm.last_prompt or "")
|
| 371 |
+
assert llm.last_image_b64 is None
|
| 372 |
+
|
| 373 |
+
def test_text_and_image_pipeline_passes_image_to_llm(
|
| 374 |
+
self, tmp_path: Path,
|
| 375 |
+
) -> None:
|
| 376 |
+
"""Mode TEXT_AND_IMAGE — le LLM reçoit l'image en plus du
|
| 377 |
+
RAW_TEXT issu de l'OCR."""
|
| 378 |
+
ocr = _MockOCREngine(fixed_text="ocr txt")
|
| 379 |
+
llm = _MockLLMAdapter()
|
| 380 |
+
|
| 381 |
+
spec = make_ocr_llm_pipeline_spec(
|
| 382 |
+
mode="text_and_image",
|
| 383 |
+
ocr_adapter_name=ocr.name,
|
| 384 |
+
llm_adapter_name="mock_llm:mock-1",
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
image_path = tmp_path / "scan.png"
|
| 388 |
+
image_path.write_bytes(b"\x89PNG fake bytes")
|
| 389 |
+
document = DocumentRef(id="doc_e2e", image_uri=str(image_path))
|
| 390 |
+
context = RunContext(
|
| 391 |
+
document_id="doc_e2e",
|
| 392 |
+
code_version="test",
|
| 393 |
+
pipeline_name=spec.name,
|
| 394 |
+
workspace_uri=str(tmp_path),
|
| 395 |
+
)
|
| 396 |
+
initial_inputs = {
|
| 397 |
+
ArtifactType.IMAGE: Artifact(
|
| 398 |
+
id="doc_e2e:initial:image",
|
| 399 |
+
document_id="doc_e2e",
|
| 400 |
+
type=ArtifactType.IMAGE,
|
| 401 |
+
uri=str(image_path),
|
| 402 |
+
),
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
executor = self._build_executor(ocr, llm)
|
| 406 |
+
result = executor.run(spec, document, initial_inputs, context)
|
| 407 |
+
|
| 408 |
+
assert result.succeeded
|
| 409 |
+
# En mode multimodal, le LLM a reçu l'image (encodée base64).
|
| 410 |
+
assert llm.last_image_b64 is not None
|
| 411 |
+
assert len(llm.last_image_b64) > 0
|