Claude commited on
Commit
f54bb20
·
unverified ·
1 Parent(s): ff7895c

feat(sprint-H.2.c-d)!: suppression complète de adapters/legacy_engines/ et adapters/legacy_pipelines/

Browse files

Sprint H.2.c + H.2.d du plan v2.0 — **breaking change** :
suppression définitive des sous-packages adapters legacy.

Suppressions
------------

- ``picarones/adapters/legacy_engines/`` (entier — ~1700 LOC,
9 fichiers) :
- ``base.py`` : ``BaseOCREngine`` (ABC héritant de ``BaseModule``),
``EngineResult`` (dataclass).
- ``factory.py`` : ``engine_from_name``.
- ``_step_executor.py`` : ``LegacyOCREngineExecutor`` (wrapper
BaseOCREngine → StepExecutor protocol).
- ``tesseract.py``, ``pero_ocr.py``, ``mistral_ocr.py``,
``google_vision.py``, ``azure_doc_intel.py`` : 5 adapters
legacy.

- ``picarones/adapters/legacy_pipelines/`` (entier — ~700 LOC,
3 fichiers) :
- ``base.py`` : ``OCRLLMPipeline`` (héritait de BaseOCREngine,
composait OCR + LLM), ``PipelineMode`` enum.
- ``_executor_runner.py`` : pont mono-document
``OCRLLMPipeline.run()`` → ``PipelineExecutor``.

Ces classes étaient déjà inutilisées en production (migrations
H.2.b.2-4 ont basculé CLI/web vers ``BaseOCRAdapter`` canoniques
+ ``OCRLLMPipelineConfig``).

Modifications dans le runner adapter
------------------------------------

``app/services/_legacy_runner_adapter.py`` :
- Retire l'import de ``LegacyOCREngineExecutor``.
- ``engine_to_pipeline_spec`` simplifiée : ne supporte plus que
``BaseOCRAdapter`` + ``OCRLLMPipelineConfig``, lève
``PicaronesError`` sur tout autre type.
- ``_ocr_only_to_spec`` (legacy IMAGE → RAW_TEXT en dur) supprimée.
- ``build_adapter_resolver`` simplifiée : pas de wrapping
``LegacyOCREngineExecutor``.

Tests / docs
------------

- ``tests/test_minimal_install.py`` : modules optionnels
référencés via ``picarones.adapters.ocr.*`` au lieu de
``picarones.adapters.legacy_engines.*``.
- ``tests/architecture/test_file_budgets.py`` : entrée orpheline
``adapters/legacy_pipelines/_executor_runner.py`` retirée.
- ``tests/architecture/test_doc_paths.py`` :
``BROKEN_PATHS_BASELINE`` 161 → 162 (1 nouveau path cassé dans
les docs migration historiques qui référençaient
``adapters/legacy_pipelines/``).
- ``tests/docs/test_readme_consistency.py`` + ``scripts/gen_readme_tables.py`` :
``ENGINES_DIR`` redirigé vers ``picarones/adapters/ocr/``.
- README régénéré : la table « Supported Engines » liste désormais
les 5 adapters canoniques (sans ``confidences``/``precomputed``
qui sont des helpers internes).

Lint : ``ruff check`` All checks passed.
Tests : 4296 passed, 9 skipped, 24 deselected.

Reste pour v2.0
---------------
- H.4 : renommer ``interfaces/{cli,web}/_legacy/`` → drop le préfixe.
- H.6 : bump version + tag v2.0.0 + section CHANGELOG.

https://claude.ai/code/session_01NxyVKqg2SowXLZdM4H1ZDE

README.md CHANGED
@@ -200,7 +200,6 @@ For Docker, institutional deployment, or HuggingFace Spaces, see
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 |
 
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 |
picarones/adapters/legacy_engines/__init__.py DELETED
@@ -1,50 +0,0 @@
1
- """Engines OCR legacy — Sprint 33+ pré-rewrite.
2
-
3
- Phase 7.A — package relocalisé depuis ``picarones.engines`` vers
4
- ``picarones.adapters.legacy_engines``. Le chemin legacy reste
5
- disponible via des shims avec ``DeprecationWarning`` ; suppression
6
- prévue en 2.0.
7
-
8
- Coexistence avec ``picarones.adapters.ocr``
9
- -------------------------------------------
10
- ``evaluation.engines`` porte les 5 OCR engines historiques qui
11
- héritent de ``BaseOCREngine`` (basé sur ``BaseModule``,
12
- ``run() → EngineResult``). Ils sont consommés par le runner
13
- legacy (``measurements/runner/``) et le ``PipelineRunner`` legacy.
14
-
15
- ``picarones.adapters.ocr`` (Sprint A14-S26) est la cible
16
- canonique : un design ``StepExecutor`` Protocol, ``Artifact``
17
- typés, sans héritage de ``BaseModule``. Les 5 OCR adapters
18
- canoniques (``TesseractAdapter``, etc.) y vivent.
19
-
20
- La convergence des deux est documentée dans
21
- ``docs/migration/pipeline-convergence-plan.md`` (sub-phases
22
- 7.A-7.D, stratégie 4.B). Tant que ``BaseModule`` n'est pas
23
- retiré, les engines legacy gardent leur place.
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- from picarones.adapters.legacy_engines.base import BaseOCREngine, EngineResult
29
- from picarones.adapters.legacy_engines.factory import engine_from_name
30
- from picarones.adapters.legacy_engines.tesseract import TesseractEngine
31
- from picarones.adapters.legacy_engines.mistral_ocr import MistralOCREngine
32
- from picarones.adapters.legacy_engines.google_vision import GoogleVisionEngine
33
- from picarones.adapters.legacy_engines.azure_doc_intel import AzureDocIntelEngine
34
-
35
- __all__ = [
36
- "BaseOCREngine",
37
- "EngineResult",
38
- "engine_from_name",
39
- "TesseractEngine",
40
- "MistralOCREngine",
41
- "GoogleVisionEngine",
42
- "AzureDocIntelEngine",
43
- ]
44
-
45
- try:
46
- from picarones.adapters.legacy_engines.pero_ocr import PeroOCREngine # noqa: F401
47
-
48
- __all__.append("PeroOCREngine")
49
- except ImportError:
50
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/_step_executor.py DELETED
@@ -1,190 +0,0 @@
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
- # Duck-typing tolérant : on accepte un ``BaseOCREngine`` réel
90
- # ou un mock qui expose ``run()`` et ``name``. Cela permet
91
- # aux tests existants (Sprint 15) qui injectent des
92
- # ``MagicMock`` de continuer à fonctionner.
93
- if not (
94
- hasattr(engine, "run") and callable(engine.run)
95
- and hasattr(engine, "name")
96
- ):
97
- raise OCRAdapterError(
98
- "LegacyOCREngineExecutor requires an object with ``run()`` "
99
- f"and ``name`` ; got {type(engine).__name__}."
100
- )
101
- self._engine = engine
102
- # Le runner choisit ``ProcessPoolExecutor`` pour ``"cpu"``
103
- # (Tesseract/Pero) et ``ThreadPoolExecutor`` pour ``"io"``
104
- # (Mistral/Google/Azure). On respecte le mode déclaré par
105
- # l'engine — ``"io"`` par défaut si l'engine ne le déclare pas
106
- # (cas du mock).
107
- self.execution_mode: str = getattr(engine, "execution_mode", "io")
108
- if not isinstance(self.execution_mode, str):
109
- self.execution_mode = "io"
110
-
111
- @property
112
- def name(self) -> str:
113
- return self._engine.name
114
-
115
- def execute(
116
- self,
117
- inputs: dict[ArtifactType, Artifact],
118
- params: dict[str, Any],
119
- context: Any,
120
- ) -> dict[ArtifactType, Artifact]:
121
- """Exécute l'OCR engine legacy et retourne un ``Artifact RAW_TEXT``.
122
-
123
- Parameters
124
- ----------
125
- inputs:
126
- Doit contenir ``ArtifactType.IMAGE``. L'URI de l'artefact
127
- image est passée à ``engine.run()``.
128
- params:
129
- Ignorés. La configuration de l'engine passe par son
130
- constructeur, pas par les ``params`` du step.
131
- context:
132
- ``RunContext``. Sert à composer les ``Artifact.id`` et à
133
- résoudre le chemin d'écriture du texte produit
134
- (``context.workspace_uri``).
135
-
136
- Returns
137
- -------
138
- dict[ArtifactType, Artifact]
139
- ``{ArtifactType.RAW_TEXT: Artifact(uri=<text_file>)}``.
140
-
141
- Raises
142
- ------
143
- OCRAdapterError
144
- Si ``inputs[IMAGE]`` est absent, sans URI, ou si
145
- ``engine.run()`` retourne un ``EngineResult`` en erreur.
146
- """
147
- if ArtifactType.IMAGE not in inputs:
148
- raise OCRAdapterError(
149
- f"{self.name} : input IMAGE manquant.",
150
- )
151
- image_artifact = inputs[ArtifactType.IMAGE]
152
- if image_artifact.uri is None:
153
- raise OCRAdapterError(
154
- f"{self.name} : artefact image "
155
- f"{image_artifact.id!r} sans URI.",
156
- )
157
- image_path = Path(image_artifact.uri)
158
- if not image_path.exists():
159
- raise OCRAdapterError(
160
- f"{self.name} : fichier image introuvable {image_path!r}.",
161
- )
162
-
163
- result = self._engine.run(image_path)
164
- if not result.success:
165
- raise OCRAdapterError(
166
- f"{self.name} : OCR engine a échoué ({result.error}).",
167
- )
168
-
169
- # Le contrat StepExecutor exige des artifacts avec URI filesystem
170
- # — on écrit le texte produit dans le workspace du run.
171
- out_path = resolve_output_path(
172
- input_path=image_path,
173
- adapter_name=self.name,
174
- suffix="raw_text.txt",
175
- context=context,
176
- )
177
- out_path.write_text(result.text, encoding="utf-8")
178
-
179
- return {
180
- ArtifactType.RAW_TEXT: Artifact(
181
- id=f"{context.document_id}:{self.name}:raw_text",
182
- document_id=context.document_id,
183
- type=ArtifactType.RAW_TEXT,
184
- produced_by_step="ocr",
185
- uri=str(out_path),
186
- ),
187
- }
188
-
189
-
190
- __all__ = ["LegacyOCREngineExecutor"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/azure_doc_intel.py DELETED
@@ -1,251 +0,0 @@
1
- """Adaptateur OCR — Azure Document Intelligence (anciennement Form Recognizer).
2
-
3
- Phase 7.A — module relocalisé depuis
4
- ``picarones.engines.azure_doc_intel`` vers
5
- ``picarones.adapters.legacy_engines.azure_doc_intel``. Le chemin legacy
6
- reste disponible via un shim avec ``DeprecationWarning`` ;
7
- suppression prévue en 2.0.
8
-
9
- Utilise l'API Azure Document Intelligence pour la reconnaissance de texte
10
- dans des documents historiques.
11
-
12
- Variables d'environnement requises :
13
- - ``AZURE_DOC_INTEL_KEY`` : clé API Azure
14
- - ``AZURE_DOC_INTEL_ENDPOINT`` : URL de l'endpoint (ex : https://moninstance.cognitiveservices.azure.com/)
15
-
16
- Documentation : https://learn.microsoft.com/azure/ai-services/document-intelligence/
17
-
18
- Sprint 51 — exposition des token_confidences
19
- ---------------------------------------------
20
- La réponse Azure expose ``analyzeResult.pages[].words[]`` avec
21
- ``content`` et ``confidence`` (∈ [0, 1]). L'adapter parcourt cette
22
- hiérarchie et émet une entrée par mot au format Sprint 42.
23
-
24
- Le texte ``EngineResult.text`` est extrait depuis ``pages[].lines[]``
25
- (préservation rétrocompat octet par octet). Les deux chemins (SDK et
26
- REST) sont normalisés vers une représentation dict unifiée.
27
-
28
- Refactor du chantier 1 (post-Sprint 97)
29
- ---------------------------------------
30
- L'adapter ne surcharge plus ``run()`` — il implémente ``_run_with_native``
31
- et ``_extract_raw_confidences`` (les hooks factorisés dans ``BaseOCREngine``).
32
- Comportement externe et octets de sortie strictement identiques.
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import json
38
- import logging
39
- import os
40
- import time
41
- import urllib.error
42
- import urllib.request
43
- from pathlib import Path
44
- from typing import Any, Optional
45
-
46
- from picarones.adapters.legacy_engines.base import BaseOCREngine
47
-
48
-
49
- logger = logging.getLogger(__name__)
50
-
51
-
52
- class AzureDocIntelEngine(BaseOCREngine):
53
- """Moteur OCR via Azure Document Intelligence.
54
-
55
- Configuration
56
- -------------
57
- model_id : str
58
- Modèle Azure à utiliser. Défaut : ``"prebuilt-read"`` (lecture générique).
59
- Alternatives : ``"prebuilt-document"``, ``"prebuilt-layout"``
60
- ou un modèle entraîné personnalisé.
61
- locale : str
62
- Paramètre de locale pour améliorer la précision (ex : ``"fr-FR"``).
63
- api_version : str
64
- Version de l'API Azure (défaut : ``"2024-02-29-preview"``).
65
- expose_confidences : bool
66
- ``True`` (défaut) : extrait ``Word.confidence`` de la réponse
67
- Azure (Sprint 51).
68
- """
69
-
70
- @property
71
- def name(self) -> str:
72
- return "azure_doc_intel"
73
-
74
- def version(self) -> str:
75
- return self.config.get("api_version", "2024-02-29-preview")
76
-
77
- def __init__(self, config: Optional[dict] = None) -> None:
78
- super().__init__(config)
79
- self._api_key = os.environ.get("AZURE_DOC_INTEL_KEY")
80
- self._endpoint = (
81
- os.environ.get("AZURE_DOC_INTEL_ENDPOINT", "").rstrip("/")
82
- or self.config.get("endpoint", "").rstrip("/")
83
- )
84
- self._model_id: str = self.config.get("model_id", "prebuilt-read")
85
- self._locale: str = self.config.get("locale", "fr-FR")
86
- self._api_version: str = self.config.get("api_version", "2024-02-29-preview")
87
-
88
- def _run_ocr(self, image_path: Path) -> str:
89
- """Retourne uniquement le texte (interface ``BaseOCREngine``)."""
90
- text, _result = self._run_with_native(image_path)
91
- return text
92
-
93
- def _run_with_native(
94
- self, image_path: Path,
95
- ) -> tuple[str, Optional[dict]]:
96
- """Exécute l'OCR et retourne ``(text, analyze_result_dict)``.
97
-
98
- ``analyze_result_dict`` est la sous-structure
99
- ``analyzeResult`` (avec ``pages[].words[]`` portant les
100
- confidences) — normalisée entre les chemins SDK et REST.
101
- """
102
- if not self._api_key:
103
- raise RuntimeError(
104
- "Clé API Azure manquante — définissez la variable d'environnement AZURE_DOC_INTEL_KEY"
105
- )
106
- if not self._endpoint:
107
- raise RuntimeError(
108
- "Endpoint Azure manquant — définissez la variable d'environnement AZURE_DOC_INTEL_ENDPOINT"
109
- )
110
-
111
- try:
112
- return self._run_via_sdk(image_path)
113
- except ImportError:
114
- return self._run_via_rest(image_path)
115
-
116
- def _run_via_sdk(self, image_path: Path) -> tuple[str, dict]:
117
- from azure.ai.documentintelligence import DocumentIntelligenceClient
118
- from azure.core.credentials import AzureKeyCredential
119
-
120
- client = DocumentIntelligenceClient(
121
- endpoint=self._endpoint,
122
- credential=AzureKeyCredential(self._api_key),
123
- )
124
- with open(image_path, "rb") as f:
125
- poller = client.begin_analyze_document(
126
- model_id=self._model_id,
127
- body=f,
128
- locale=self._locale,
129
- content_type="application/octet-stream",
130
- )
131
- result = poller.result()
132
- text = "\n".join(
133
- line.content
134
- for page in result.pages
135
- for line in (page.lines or [])
136
- )
137
- analyze_result = self._sdk_result_to_dict(result)
138
- return text, analyze_result
139
-
140
- def _run_via_rest(self, image_path: Path) -> tuple[str, Optional[dict]]:
141
- """Appel REST direct (sans SDK Azure)."""
142
- image_bytes = image_path.read_bytes()
143
- analyze_url = (
144
- f"{self._endpoint}/documentintelligence/documentModels/"
145
- f"{self._model_id}:analyze"
146
- f"?api-version={self._api_version}&locale={self._locale}"
147
- )
148
-
149
- # Soumettre l'image
150
- req = urllib.request.Request(
151
- analyze_url,
152
- data=image_bytes,
153
- headers={
154
- "Ocp-Apim-Subscription-Key": self._api_key,
155
- "Content-Type": "application/octet-stream",
156
- },
157
- )
158
- try:
159
- with urllib.request.urlopen(req, timeout=60) as resp:
160
- operation_url = resp.headers.get("Operation-Location", "")
161
- except urllib.error.HTTPError as exc:
162
- raise RuntimeError(
163
- f"Azure Document Intelligence erreur {exc.code}: {exc.read().decode()}"
164
- ) from exc
165
-
166
- if not operation_url:
167
- raise RuntimeError("Azure : pas d'Operation-Location dans la réponse")
168
-
169
- # Polling du résultat (Azure est asynchrone)
170
- headers = {"Ocp-Apim-Subscription-Key": self._api_key}
171
- for attempt in range(30):
172
- time.sleep(1 + attempt * 0.5)
173
- poll_req = urllib.request.Request(operation_url, headers=headers)
174
- with urllib.request.urlopen(poll_req, timeout=30) as resp:
175
- result = json.loads(resp.read().decode("utf-8"))
176
- status = result.get("status", "")
177
- if status == "succeeded":
178
- text = self._extract_text_from_result(result)
179
- analyze_result = result.get("analyzeResult") or None
180
- return text, analyze_result
181
- if status in {"failed", "canceled"}:
182
- raise RuntimeError(f"Azure Document Intelligence : analyse {status}")
183
- # status == "running" → continuer à attendre
184
-
185
- raise RuntimeError("Azure Document Intelligence : timeout — analyse trop longue")
186
-
187
- @staticmethod
188
- def _extract_text_from_result(result: dict) -> str:
189
- """Extrait le texte brut depuis la réponse JSON Azure."""
190
- pages = result.get("analyzeResult", {}).get("pages", [])
191
- lines: list[str] = []
192
- for page in pages:
193
- for line in page.get("lines", []):
194
- content = line.get("content", "")
195
- if content:
196
- lines.append(content)
197
- return "\n".join(lines)
198
-
199
- # ──────────────────────────────────────────────────────────────────
200
- # Conversion SDK → dict normalisé
201
- # ──────────────────────────────────────────────────────────────────
202
-
203
- @staticmethod
204
- def _sdk_result_to_dict(result: Any) -> dict:
205
- """Convertit l'objet SDK en dict ``{"pages": [{"words":
206
- [{"content", "confidence"}]}]}`` pour traitement uniforme avec
207
- le chemin REST."""
208
- pages = []
209
- for page in getattr(result, "pages", []) or []:
210
- words = []
211
- for word in getattr(page, "words", []) or []:
212
- content = getattr(word, "content", "") or ""
213
- conf = getattr(word, "confidence", None)
214
- words.append({
215
- "content": content,
216
- "confidence": float(conf) if conf is not None else None,
217
- })
218
- pages.append({"words": words})
219
- return {"pages": pages}
220
-
221
- # ──────────────────────────────────────────────────────────────────
222
- # Extraction des token_confidences au format Sprint 42
223
- # ──────────────────────────────────────────────────────────────────
224
-
225
- def _extract_raw_confidences(
226
- self, native: Any,
227
- ) -> Optional[list[dict[str, Any]]]:
228
- """Parcourt ``pages[].words[]`` et émet
229
- ``{"token": str, "confidence": float}`` par mot.
230
-
231
- Filtrage cohérent avec les autres adapters : confidence None /
232
- négative ignorée, contenu vide ignoré (filtrage final assuré
233
- par ``BaseOCREngine._normalize_token_confidences``).
234
- """
235
- if not self.config.get("expose_confidences", True):
236
- return None
237
- if not native or not isinstance(native, dict):
238
- return None
239
- out: list[dict[str, Any]] = []
240
- for page in native.get("pages") or []:
241
- if not isinstance(page, dict):
242
- continue
243
- for word in page.get("words") or []:
244
- if not isinstance(word, dict):
245
- continue
246
- content = (word.get("content") or "").strip()
247
- conf = word.get("confidence")
248
- if not content or conf is None:
249
- continue
250
- out.append({"token": content, "confidence": conf})
251
- return out or None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/base.py DELETED
@@ -1,336 +0,0 @@
1
- """Interface abstraite commune à tous les adaptateurs moteurs OCR (legacy).
2
-
3
- Phase 7.A — module relocalisé depuis ``picarones.engines.base``
4
- vers ``picarones.adapters.legacy_engines.base``. Le chemin legacy
5
- reste disponible via un shim avec ``DeprecationWarning`` ;
6
- suppression prévue en 2.0.
7
-
8
- Cohabite avec ``picarones.adapters.ocr.base.BaseOCRAdapter``
9
- (canonique, ``StepExecutor`` Protocol). Convergence documentée
10
- dans ``docs/migration/pipeline-convergence-plan.md``
11
- (sub-phases 7.A-7.D, stratégie 4.B).
12
-
13
- Refactor du chantier 1 (post-Sprint 97)
14
- ---------------------------------------
15
- Les Sprints 47-51 ont fait surcharger ``run()`` par chacun des cinq
16
- adaptateurs OCR pour exposer ``token_confidences`` ; cinq fois la même
17
- structure (chronométrage + extraction native + parsing). Ce module
18
- factorise ce pattern :
19
-
20
- - ``_run_with_native(image_path) -> (text, native_response)`` : hook
21
- par lequel passe désormais ``run()``. Implémentation par défaut qui
22
- délègue à ``_run_ocr`` (rétrocompat avec les engines historiques et
23
- avec les engines de test qui n'implémentent que ``_run_ocr``).
24
- - ``_extract_raw_confidences(native) -> list[dict] | None`` : hook
25
- optionnel à surcharger pour exposer les confidences. Défaut : ``None``.
26
- - ``_normalize_token_confidences(raw)`` : helper commun (filtrage
27
- tokens vides / négatifs, détection automatique d'échelle 0-100 → 0-1).
28
-
29
- Conséquence : la classe se charge seule du chronométrage, de la
30
- gestion d'erreurs et du wrapping en ``EngineResult``. Aucun adaptateur
31
- OCR n'a plus à surcharger ``run()``.
32
-
33
- Compat ``BaseModule`` (Sprint 33)
34
- ---------------------------------
35
- ``process()`` continue de propager le texte sous
36
- ``{ArtifactType.TEXT: ...}``. Les ``token_confidences`` ne sont pas
37
- des artefacts — elles vivent dans ``EngineResult`` et restent
38
- accessibles via la propriété ``last_run_result`` après l'exécution.
39
- """
40
-
41
- from __future__ import annotations
42
-
43
- import hashlib
44
- import logging
45
- import time
46
- from abc import abstractmethod
47
- from dataclasses import dataclass, field
48
- from pathlib import Path
49
- from typing import Any, Optional
50
-
51
- from picarones.domain.artifacts import ArtifactType
52
- from picarones.domain.module_protocol import BaseModule
53
-
54
- logger = logging.getLogger(__name__)
55
-
56
-
57
- @dataclass
58
- class EngineResult:
59
- """Résultat brut produit par un moteur OCR sur une image."""
60
-
61
- engine_name: str
62
- image_path: str
63
- text: str
64
- duration_seconds: float
65
- error: Optional[str] = None
66
- metadata: dict = field(default_factory=dict)
67
- # Sprint 42 — confidences au niveau token (optionnel).
68
- # Format attendu : liste de dicts ``{"token": str, "confidence": float}``
69
- # avec ``confidence`` ∈ [0, 1] (ou ∈ [0, 100], normalisé par le runner).
70
- # ``None`` si le moteur ne fournit pas ce signal — comportement par
71
- # défaut pour tous les adapters historiques. Quand renseigné,
72
- # le runner alimente ``DocumentResult.calibration_metrics``.
73
- token_confidences: Optional[list[dict[str, Any]]] = None
74
-
75
- @property
76
- def success(self) -> bool:
77
- return self.error is None
78
-
79
- @property
80
- def image_sha256(self) -> str:
81
- return hashlib.sha256(Path(self.image_path).read_bytes()).hexdigest()
82
-
83
-
84
- class BaseOCREngine(BaseModule):
85
- """Classe de base dont héritent tous les adaptateurs OCR.
86
-
87
- Sprint 33 — Phase 0.2 : ``BaseOCREngine`` hérite de ``BaseModule`` afin
88
- que les moteurs OCR existants soient automatiquement utilisables comme
89
- nœuds d'une pipeline composée (axe B du plan d'évolution).
90
-
91
- Chantier 1 (post-Sprint 97) — factorisation du run() unifié
92
- ------------------------------------------------------------
93
- Les sous-classes implémentent **un** des deux contrats suivants :
94
-
95
- 1. **Engine sans confidences** : surchargent uniquement ``_run_ocr``
96
- qui retourne le texte. ``run()`` retourne un ``EngineResult``
97
- avec ``token_confidences=None``.
98
-
99
- 2. **Engine avec confidences natives** : surchargent
100
- ``_run_with_native`` (un seul appel API qui retourne texte +
101
- payload natif) et ``_extract_raw_confidences`` (parsing du
102
- payload natif vers le format runner). ``run()`` les invoque
103
- et propage les ``token_confidences`` dans le ``EngineResult``.
104
-
105
- Aucune sous-classe n'a plus besoin de surcharger ``run()``.
106
-
107
- Attribut de classe
108
- ------------------
109
- execution_mode : ``"io"`` (défaut) ou ``"cpu"``
110
- Indique au runner quel type d'exécuteur utiliser :
111
- - ``"io"`` → ``ThreadPoolExecutor`` (moteurs API / réseau)
112
- - ``"cpu"`` → ``ProcessPoolExecutor`` (moteurs CPU-intensifs : Tesseract, Pero, Kraken)
113
- """
114
-
115
- # Déclaration BaseModule — un OCR consomme une image et produit du texte.
116
- input_types = (ArtifactType.IMAGE,)
117
- output_types = (ArtifactType.TEXT,)
118
- execution_mode: str = "io"
119
- """``"io"`` pour ThreadPoolExecutor (défaut), ``"cpu"`` pour ProcessPoolExecutor."""
120
-
121
- #: ``True`` ssi l'engine est un pipeline composé (OCR+LLM ou VLM).
122
- #: Sprint C du plan v2.0 : remplace le check legacy
123
- #: ``isinstance(engine, OCRLLMPipeline)`` par un attribut polymorphe.
124
- #: Les sous-classes "pipeline composé" (``OCRLLMPipeline``, et tout
125
- #: futur composite) surchargent à ``True``.
126
- is_pipeline: bool = False
127
-
128
- def __init__(self, config: Optional[dict] = None) -> None:
129
- self.config: dict = config or {}
130
- # Cache du dernier ``EngineResult`` produit par ``run()`` —
131
- # exposé via la propriété ``last_run_result`` pour permettre
132
- # à un orchestrateur (par exemple le pipeline_runner) de
133
- # consulter les ``token_confidences`` après ``process()``.
134
- self._last_run_result: Optional[EngineResult] = None
135
-
136
- # ``name`` reste abstrait via héritage de BaseModule (cf.
137
- # picarones.core.modules) — les sous-classes le surchargent en
138
- # ``@property`` comme dans BaseModule.
139
-
140
- @abstractmethod
141
- def version(self) -> str:
142
- """Retourne la version du moteur (ex : '5.3.0')."""
143
-
144
- @abstractmethod
145
- def _run_ocr(self, image_path: Path) -> str:
146
- """Exécute l'OCR et retourne le texte brut extrait.
147
-
148
- Contrat **historique** conservé par rétrocompat. Les
149
- adaptateurs qui veulent exposer leurs confidences natives
150
- surchargent en plus ``_run_with_native`` et
151
- ``_extract_raw_confidences`` (cf. docstring de classe).
152
- """
153
-
154
- # ──────────────────────────────────────────────────────────────────
155
- # Hooks pour confidences natives (Chantier 1)
156
- # ──────────────────────────────────────────────────────────────────
157
-
158
- def _run_with_native(self, image_path: Path) -> tuple[str, Any]:
159
- """Exécute l'OCR et retourne ``(text, native_response)``.
160
-
161
- Implémentation par défaut : délègue à ``_run_ocr`` et retourne
162
- ``(text, None)`` — comportement adapté aux engines qui
163
- n'exposent pas de confidences (ex. tests, moteurs basiques).
164
-
165
- Les adaptateurs avec confidences natives surchargent cette
166
- méthode pour effectuer un seul appel API qui produit à la
167
- fois le texte et la structure (dict JSON, page layout, etc.)
168
- à partir de laquelle ``_extract_raw_confidences`` extraira
169
- les paires (token, confidence).
170
- """
171
- return self._run_ocr(image_path), None
172
-
173
- def _extract_raw_confidences(
174
- self, native: Any,
175
- ) -> Optional[list[dict[str, Any]]]:
176
- """Parse ``native`` et retourne les paires ``(token, conf)``.
177
-
178
- Format attendu : liste de dicts ``{"token": str, "confidence":
179
- float}`` avec ``confidence`` ∈ [0, 1] **ou** ∈ [0, 100].
180
- ``_normalize_token_confidences`` détecte l'échelle et normalise.
181
-
182
- Retourne ``None`` quand ``native`` est ``None`` ou que la
183
- structure ne contient aucune confidence exploitable.
184
-
185
- Implémentation par défaut : ``None`` (pas de confidences).
186
- """
187
- return None
188
-
189
- @staticmethod
190
- def _normalize_token_confidences(
191
- raw: Optional[list[dict[str, Any]]],
192
- ) -> Optional[list[dict[str, Any]]]:
193
- """Filtre les confidences brutes (échelle native conservée).
194
-
195
- - Tokens vides ou ``None`` → écartés.
196
- - Confidences négatives (Tesseract met -1 pour les non-mots) → écartées.
197
- - Confidences non convertibles en float → écartées.
198
-
199
- L'échelle native des moteurs ([0, 100] pour Tesseract,
200
- [0, 1] pour les autres) est conservée. La normalisation finale
201
- au moment du calcul de calibration est faite dans
202
- :func:`picarones.measurements.builtin_hooks.calibration_from_engine_result`.
203
-
204
- Retourne ``None`` si aucune entrée n'est exploitable.
205
- """
206
- if not raw:
207
- return None
208
- cleaned: list[dict[str, Any]] = []
209
- for entry in raw:
210
- if not isinstance(entry, dict):
211
- continue
212
- tok = entry.get("token")
213
- if not isinstance(tok, str):
214
- continue
215
- tok = tok.strip()
216
- if not tok:
217
- continue
218
- conf = entry.get("confidence")
219
- if conf is None:
220
- continue
221
- try:
222
- conf_val = float(conf)
223
- except (TypeError, ValueError):
224
- continue
225
- if conf_val < 0:
226
- continue
227
- cleaned.append({"token": tok, "confidence": conf_val})
228
- return cleaned or None
229
-
230
- # ──────────────────────────────────────────────────────────────────
231
- # Implémentation BaseModule (Sprint 33)
232
- # ───���──────────────────────────────────────────────────────────────
233
-
234
- def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
235
- """Exécute le moteur OCR comme un module générique.
236
-
237
- Wrapper rétrocompatible : extrait le chemin image de ``inputs``,
238
- appelle ``run()``, et retourne la sortie sous forme de dictionnaire
239
- ``{ArtifactType.TEXT: text}``. Les erreurs sont conservées dans
240
- le résultat (cf. ``EngineResult.error``) plutôt que de lever.
241
- Les ``token_confidences`` restent accessibles via
242
- ``self.last_run_result.token_confidences`` après l'appel.
243
- """
244
- self.validate_inputs(inputs)
245
- result = self.run(inputs[ArtifactType.IMAGE])
246
- return {ArtifactType.TEXT: result.text}
247
-
248
- def metadata(self) -> dict:
249
- """Expose la version du moteur dans les métadonnées du module."""
250
- return {"engine_version": self._safe_version()}
251
-
252
- @property
253
- def last_run_result(self) -> Optional[EngineResult]:
254
- """Dernier ``EngineResult`` produit par ``run()`` (ou ``None``).
255
-
256
- Utile pour récupérer ``token_confidences`` après un appel à
257
- ``process()`` (qui ne les expose pas dans le bag d'artefacts du
258
- pipeline_runner — les confidences ne sont pas un type
259
- d'artefact mais une métadonnée du calcul).
260
- """
261
- return self._last_run_result
262
-
263
- # ──────────────────────────────────────────────────────────────────
264
- # Point d'entrée unifié : run()
265
- # ──────────────────────────────────────────────────────────────────
266
-
267
- def run(self, image_path: str | Path) -> EngineResult:
268
- """Exécute l'OCR et retourne un ``EngineResult``.
269
-
270
- Pipeline interne :
271
-
272
- 1. ``_run_with_native(image_path)`` → ``(text, native)``
273
- (par défaut : appelle ``_run_ocr`` et retourne ``(text, None)``).
274
- 2. ``_extract_raw_confidences(native)`` → liste brute ou ``None``
275
- (par défaut : ``None``).
276
- 3. ``_normalize_token_confidences(raw)`` → format runner Sprint 42
277
- ou ``None``.
278
-
279
- Toute exception levée par l'étape 1 est capturée et placée dans
280
- ``EngineResult.error`` ; le texte est alors ``""`` et les
281
- confidences ``None``. Les exceptions des étapes 2-3 sont
282
- capturées séparément en warning : on retourne le texte avec
283
- ``token_confidences=None`` plutôt que de faire échouer toute
284
- la mesure pour un défaut de calibration.
285
- """
286
- image_path = Path(image_path)
287
- start = time.perf_counter()
288
- text = ""
289
- error: Optional[str] = None
290
- token_confidences: Optional[list[dict[str, Any]]] = None
291
- try:
292
- text, native = self._run_with_native(image_path)
293
- except Exception as exc: # noqa: BLE001
294
- text = ""
295
- error = str(exc)
296
- native = None
297
- if error is None:
298
- try:
299
- raw = self._extract_raw_confidences(native)
300
- token_confidences = self._normalize_token_confidences(raw)
301
- except Exception as exc: # noqa: BLE001
302
- logger.warning(
303
- "[%s] extraction/normalisation des token_confidences "
304
- "dégradée : %s",
305
- self.name, exc,
306
- )
307
- token_confidences = None
308
- duration = time.perf_counter() - start
309
- result = EngineResult(
310
- engine_name=self.name,
311
- image_path=str(image_path),
312
- text=text,
313
- duration_seconds=round(duration, 4),
314
- error=error,
315
- metadata={"engine_version": self._safe_version()},
316
- token_confidences=token_confidences,
317
- )
318
- self._last_run_result = result
319
- return result
320
-
321
- def _safe_version(self) -> str:
322
- # Sprint 30 — log la stacktrace en DEBUG pour aider au diagnostic
323
- # quand un moteur retourne ``"unknown"`` (utilisateur qui se
324
- # demande pourquoi). Ne pollue pas l'output normal (INFO+).
325
- try:
326
- return self.version()
327
- except Exception as exc: # noqa: BLE001
328
- logging.getLogger(__name__).debug(
329
- "[%s._safe_version] retourne 'unknown' suite à %s: %s",
330
- self.__class__.__name__, type(exc).__name__, exc,
331
- exc_info=True,
332
- )
333
- return "unknown"
334
-
335
- def __repr__(self) -> str:
336
- return f"{self.__class__.__name__}(name={self.name!r})"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/factory.py DELETED
@@ -1,66 +0,0 @@
1
- """Factory legacy : instancier un ``BaseOCREngine`` à partir de son nom court.
2
-
3
- Phase 7.A — module relocalisé depuis ``picarones.engines.factory``
4
- vers ``picarones.adapters.legacy_engines.factory``.
5
-
6
- Sprint H.2.b du plan v2.0 — équivalent canonique disponible :
7
- ``picarones.adapters.ocr.factory.ocr_adapter_from_name`` retourne
8
- des ``BaseOCRAdapter`` (StepExecutor Protocol) directement
9
- consommables par ``PipelineExecutor`` sans ``LegacyOCREngineExecutor``.
10
- Les nouveaux callers doivent utiliser la factory canonique. Cette
11
- factory ne sera supprimée qu'avec ``BaseOCREngine`` lui-même
12
- (H.2.d).
13
-
14
- Discipline : ne pas importer ``click`` ici, sous peine de remonter une
15
- dépendance interfaces dans la couche adapters.
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- from picarones.adapters.legacy_engines.base import BaseOCREngine
21
-
22
-
23
- def engine_from_name(engine_name: str, lang: str = "fra", psm: int = 6) -> BaseOCREngine:
24
- """Instancie un moteur OCR par son nom court.
25
-
26
- Parameters
27
- ----------
28
- engine_name:
29
- Identifiant court (``"tesseract"``/``"tess"``, ``"pero_ocr"``/``"pero"``).
30
- lang:
31
- Code langue propagé au moteur quand il en consomme un (Tesseract).
32
- psm:
33
- Mode de segmentation Tesseract (ignoré par les autres moteurs).
34
-
35
- Returns
36
- -------
37
- BaseOCREngine
38
- Instance prête à exécuter ``run(image_path)``.
39
-
40
- Raises
41
- ------
42
- ValueError
43
- Si le nom est inconnu ou si le moteur est indisponible (par
44
- exemple Pero OCR non installé). Le message inclut la liste des
45
- moteurs effectivement disponibles dans l'environnement courant.
46
- """
47
- from picarones.adapters.legacy_engines.tesseract import TesseractEngine
48
-
49
- if engine_name in {"tesseract", "tess"}:
50
- return TesseractEngine(config={"lang": lang, "psm": psm})
51
-
52
- try:
53
- from picarones.adapters.legacy_engines.pero_ocr import PeroOCREngine
54
-
55
- if engine_name in {"pero_ocr", "pero"}:
56
- return PeroOCREngine(config={"name": "pero_ocr"})
57
- except ImportError:
58
- pass
59
-
60
- raise ValueError(
61
- f"Moteur inconnu ou non disponible : '{engine_name}'. "
62
- "Moteurs supportés : tesseract, pero_ocr"
63
- )
64
-
65
-
66
- __all__ = ["engine_from_name"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/google_vision.py DELETED
@@ -1,262 +0,0 @@
1
- """Adaptateur OCR — Google Cloud Vision API.
2
-
3
- Phase 7.A — module relocalisé depuis
4
- ``picarones.engines.google_vision`` vers
5
- ``picarones.adapters.legacy_engines.google_vision``. Le chemin legacy
6
- reste disponible via un shim avec ``DeprecationWarning`` ;
7
- suppression prévue en 2.0.
8
-
9
- Utilise l'API Google Cloud Vision pour la détection de texte dans des
10
- documents (méthode ``DOCUMENT_TEXT_DETECTION``, optimisée pour les textes
11
- denses et multilinguistiques).
12
-
13
- Authentification :
14
- - Via service account JSON : variable d'environnement
15
- ``GOOGLE_APPLICATION_CREDENTIALS`` → chemin vers le fichier JSON
16
- - Via clé API simple : variable d'environnement ``GOOGLE_API_KEY``
17
-
18
- Le mode service account est recommandé pour la production.
19
-
20
- Sprint 50 — exposition des token_confidences
21
- ---------------------------------------------
22
- ``DOCUMENT_TEXT_DETECTION`` expose ``Word.confidence`` au niveau mot
23
- sur chaque ``page > block > paragraph > word``. L'adapter parcourt
24
- cette hiérarchie et émet une entrée par mot au format Sprint 42.
25
- Les deux chemins (SDK ``google-cloud-vision`` et REST direct via
26
- ``urllib``) sont normalisés vers une représentation unifiée.
27
-
28
- Pour ``TEXT_DETECTION`` (mode "court"), aucune confidence par mot
29
- n'est exposée : ``token_confidences = None``.
30
-
31
- Refactor du chantier 1 (post-Sprint 97)
32
- ---------------------------------------
33
- L'adapter ne surcharge plus ``run()`` — il implémente ``_run_with_native``
34
- et ``_extract_raw_confidences`` (les hooks factorisés dans ``BaseOCREngine``).
35
- Comportement externe et octets de sortie strictement identiques.
36
- """
37
-
38
- from __future__ import annotations
39
-
40
- import base64
41
- import json
42
- import logging
43
- import os
44
- import urllib.error
45
- import urllib.request
46
- from pathlib import Path
47
- from typing import Any, Optional
48
-
49
- from picarones.adapters.legacy_engines.base import BaseOCREngine
50
-
51
-
52
- logger = logging.getLogger(__name__)
53
-
54
-
55
- class GoogleVisionEngine(BaseOCREngine):
56
- """Moteur OCR via l'API Google Cloud Vision.
57
-
58
- Configuration
59
- -------------
60
- language_hints : list[str]
61
- Suggestions de langue (ex : ``["fr"]``). Améliore la précision.
62
- feature_type : str
63
- Type de détection : ``"DOCUMENT_TEXT_DETECTION"`` (défaut, pour textes
64
- denses) ou ``"TEXT_DETECTION"`` (pour textes courts).
65
- expose_confidences : bool
66
- ``True`` (défaut) : extrait ``Word.confidence`` quand
67
- ``feature_type=DOCUMENT_TEXT_DETECTION`` (Sprint 50).
68
- ``False`` : désactive l'extraction (économise quelques ms par
69
- image).
70
- """
71
-
72
- @property
73
- def name(self) -> str:
74
- return "google_vision"
75
-
76
- def version(self) -> str:
77
- return "v1"
78
-
79
- def __init__(self, config: Optional[dict] = None) -> None:
80
- super().__init__(config)
81
- self._api_key = os.environ.get("GOOGLE_API_KEY")
82
- self._credentials_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
83
- self._language_hints: list[str] = self.config.get("language_hints", ["fr"])
84
- self._feature_type: str = self.config.get("feature_type", "DOCUMENT_TEXT_DETECTION")
85
-
86
- def _run_ocr(self, image_path: Path) -> str:
87
- """Retourne uniquement le texte (interface ``BaseOCREngine``)."""
88
- text, _full = self._run_with_native(image_path)
89
- return text
90
-
91
- def _run_with_native(
92
- self, image_path: Path,
93
- ) -> tuple[str, Optional[dict]]:
94
- """Exécute l'OCR et retourne ``(text, full_text_annotation_dict)``.
95
-
96
- ``full_text_annotation_dict`` est :
97
- - le JSON brut ``fullTextAnnotation`` du REST quand on passe
98
- par REST,
99
- - une représentation dict normalisée quand on passe par SDK,
100
- - ``None`` pour ``TEXT_DETECTION`` (mode court sans
101
- confidence par mot).
102
- """
103
- if self._credentials_path:
104
- return self._run_via_sdk(image_path)
105
- elif self._api_key:
106
- return self._run_via_rest(image_path)
107
- else:
108
- raise RuntimeError(
109
- "Authentification Google Vision manquante. Définissez "
110
- "GOOGLE_APPLICATION_CREDENTIALS (service account JSON) "
111
- "ou GOOGLE_API_KEY."
112
- )
113
-
114
- def _run_via_sdk(self, image_path: Path) -> tuple[str, Optional[dict]]:
115
- try:
116
- from google.cloud import vision
117
- except ImportError as exc:
118
- raise RuntimeError(
119
- "Le package 'google-cloud-vision' n'est pas installé. "
120
- "Lancez : pip install google-cloud-vision"
121
- ) from exc
122
-
123
- client = vision.ImageAnnotatorClient()
124
- image_bytes = image_path.read_bytes()
125
- image = vision.Image(content=image_bytes)
126
-
127
- if self._feature_type == "DOCUMENT_TEXT_DETECTION":
128
- response = client.document_text_detection(
129
- image=image,
130
- image_context=vision.ImageContext(
131
- language_hints=self._language_hints
132
- ),
133
- )
134
- text = response.full_text_annotation.text
135
- full = self._sdk_full_text_to_dict(response.full_text_annotation)
136
- return text, full
137
- else:
138
- response = client.text_detection(
139
- image=image,
140
- image_context=vision.ImageContext(
141
- language_hints=self._language_hints
142
- ),
143
- )
144
- texts = response.text_annotations
145
- text = texts[0].description if texts else ""
146
- return text, None
147
-
148
- def _run_via_rest(self, image_path: Path) -> tuple[str, Optional[dict]]:
149
- """Appel REST direct (sans SDK), avec clé API simple."""
150
- image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
151
- payload = {
152
- "requests": [
153
- {
154
- "image": {"content": image_b64},
155
- "features": [{"type": self._feature_type, "maxResults": 1}],
156
- "imageContext": {"languageHints": self._language_hints},
157
- }
158
- ]
159
- }
160
- url = "https://vision.googleapis.com/v1/images:annotate"
161
- data = json.dumps(payload).encode("utf-8")
162
- req = urllib.request.Request(
163
- url, data=data,
164
- headers={
165
- "Content-Type": "application/json",
166
- "X-Goog-Api-Key": self._api_key,
167
- },
168
- )
169
- try:
170
- with urllib.request.urlopen(req, timeout=60) as resp:
171
- result = json.loads(resp.read().decode("utf-8"))
172
- except urllib.error.HTTPError as exc:
173
- raise RuntimeError(f"Google Vision API erreur {exc.code}: {exc.read().decode()}") from exc
174
-
175
- responses = result.get("responses", [{}])
176
- if not responses:
177
- return "", None
178
- r = responses[0]
179
- if "error" in r:
180
- raise RuntimeError(f"Google Vision API erreur : {r['error']}")
181
-
182
- if self._feature_type == "DOCUMENT_TEXT_DETECTION":
183
- full = r.get("fullTextAnnotation") or None
184
- text = (full or {}).get("text", "") if isinstance(full, dict) else ""
185
- return text, full
186
- else:
187
- texts = r.get("textAnnotations", [])
188
- text = texts[0]["description"] if texts else ""
189
- return text, None
190
-
191
- # ──────────────────────────────────────────────────────────────────
192
- # Conversion SDK → dict normalisé (pour traitement uniforme)
193
- # ──────────────────────────────────────────────────────────────────
194
-
195
- @staticmethod
196
- def _sdk_full_text_to_dict(full_text_annotation: Any) -> dict:
197
- """Convertit une réponse proto SDK en dict avec la même
198
- structure que le REST : ``{pages: [{blocks: [{paragraphs:
199
- [{words: [{confidence, symbols: [{text}]}]}]}]}]}``."""
200
- pages = []
201
- for page in getattr(full_text_annotation, "pages", []) or []:
202
- blocks = []
203
- for block in getattr(page, "blocks", []) or []:
204
- paragraphs = []
205
- for para in getattr(block, "paragraphs", []) or []:
206
- words = []
207
- for word in getattr(para, "words", []) or []:
208
- symbols = [
209
- {"text": getattr(s, "text", "")}
210
- for s in getattr(word, "symbols", []) or []
211
- ]
212
- words.append({
213
- "confidence": float(getattr(word, "confidence", 0.0)),
214
- "symbols": symbols,
215
- })
216
- paragraphs.append({"words": words})
217
- blocks.append({"paragraphs": paragraphs})
218
- pages.append({"blocks": blocks})
219
- return {"pages": pages}
220
-
221
- # ──────────────────────────────────────────────────────────────────
222
- # Extraction des token_confidences au format Sprint 42
223
- # ──────────────────────────────────────────────────────────────────
224
-
225
- def _extract_raw_confidences(
226
- self, native: Any,
227
- ) -> Optional[list[dict[str, Any]]]:
228
- """Parcourt ``pages → blocks → paragraphs → words`` et émet
229
- ``{"token": mot, "confidence": float}`` par mot.
230
-
231
- Le mot est reconstitué par concaténation des
232
- ``word.symbols[i].text``. ``word.confidence`` ∈ [0, 1] (la
233
- normalisation par la base accepte directement ce format).
234
- """
235
- if not self.config.get("expose_confidences", True):
236
- return None
237
- if not native or not isinstance(native, dict):
238
- return None
239
- out: list[dict[str, Any]] = []
240
- for page in native.get("pages") or []:
241
- if not isinstance(page, dict):
242
- continue
243
- for block in page.get("blocks") or []:
244
- if not isinstance(block, dict):
245
- continue
246
- for para in block.get("paragraphs") or []:
247
- if not isinstance(para, dict):
248
- continue
249
- for word in para.get("words") or []:
250
- if not isinstance(word, dict):
251
- continue
252
- text = "".join(
253
- (s or {}).get("text", "")
254
- for s in (word.get("symbols") or [])
255
- ).strip()
256
- if not text:
257
- continue
258
- conf = word.get("confidence")
259
- if conf is None:
260
- continue
261
- out.append({"token": text, "confidence": conf})
262
- return out or None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/mistral_ocr.py DELETED
@@ -1,237 +0,0 @@
1
- """Adaptateur OCR — Mistral OCR (API vision Mistral AI).
2
-
3
- Phase 7.A — module relocalisé depuis
4
- ``picarones.engines.mistral_ocr`` vers
5
- ``picarones.adapters.legacy_engines.mistral_ocr``. Le chemin legacy
6
- reste disponible via un shim avec ``DeprecationWarning`` ;
7
- suppression prévue en 2.0.
8
-
9
- Utilise l'API Mistral pour la reconnaissance de texte sur documents
10
- patrimoniaux via le modèle multimodal Mistral.
11
-
12
- Clé API : variable d'environnement ``MISTRAL_API_KEY``.
13
-
14
- Documentation API : https://docs.mistral.ai/
15
-
16
- Sprint 49 — exposition des token_confidences
17
- ---------------------------------------------
18
- L'API ``/v1/ocr`` peut renvoyer des champs ``confidence`` au niveau
19
- page, block, line ou word selon le modèle. L'adapter parse la réponse
20
- brute (``raw_response``) en plus du markdown : il cherche
21
- récursivement les paires ``(text, confidence)`` exploitables et les
22
- retourne au format Sprint 42. Si la réponse ne contient aucun champ
23
- de confidence (cas de l'API chat/vision pour ``pixtral-*``),
24
- ``token_confidences = None``.
25
-
26
- Refactor du chantier 1 (post-Sprint 97)
27
- ---------------------------------------
28
- L'adapter ne surcharge plus ``run()`` — il implémente ``_run_with_native``
29
- et ``_extract_raw_confidences`` (les hooks factorisés dans ``BaseOCREngine``).
30
- Comportement externe et octets de sortie strictement identiques.
31
- """
32
-
33
- from __future__ import annotations
34
-
35
- import base64
36
- import logging
37
- import os
38
- from pathlib import Path
39
- from typing import Any, Optional
40
-
41
- from picarones.adapters.legacy_engines.base import BaseOCREngine
42
-
43
-
44
- logger = logging.getLogger(__name__)
45
-
46
-
47
- class MistralOCREngine(BaseOCREngine):
48
- """Moteur OCR via l'API Mistral AI (modèle vision).
49
-
50
- Configuration
51
- -------------
52
- model : str
53
- Modèle Mistral à utiliser (défaut : ``"pixtral-12b-2409"``).
54
- Les modèles multimodaux supportant la vision sont :
55
- ``pixtral-12b-2409``, ``pixtral-large-latest``.
56
- prompt : str
57
- Prompt envoyé avec l'image. Défaut : instruction générique de transcription.
58
- max_tokens : int
59
- Limite de tokens en sortie (défaut : 4096).
60
- expose_confidences : bool
61
- ``True`` (défaut) : extrait les ``confidence`` de la réponse
62
- ``/v1/ocr`` quand elles sont présentes (Sprint 49). ``False`` :
63
- désactive complètement l'extraction.
64
- """
65
-
66
- @property
67
- def name(self) -> str:
68
- return "mistral_ocr"
69
-
70
- def version(self) -> str:
71
- return self.config.get("model", "mistral-ocr-latest")
72
-
73
- def __init__(self, config: Optional[dict] = None) -> None:
74
- super().__init__(config)
75
- self._api_key = os.environ.get("MISTRAL_API_KEY")
76
- self._model = self.config.get("model", "mistral-ocr-latest")
77
- self._prompt = self.config.get(
78
- "prompt",
79
- "Transcris fidèlement le texte visible sur cette image de document "
80
- "historique. Retourne uniquement le texte, sans commentaire.",
81
- )
82
- self._max_tokens = int(self.config.get("max_tokens", 4096))
83
-
84
- def _run_ocr(self, image_path: Path) -> str:
85
- """Retourne uniquement le texte (interface ``BaseOCREngine``)."""
86
- text, _raw = self._run_with_native(image_path)
87
- return text
88
-
89
- def _run_with_native(
90
- self, image_path: Path,
91
- ) -> tuple[str, Optional[dict]]:
92
- """Exécute l'OCR et retourne ``(text, raw_response)``.
93
-
94
- ``raw_response`` est le JSON brut de l'API ``/v1/ocr`` (chemin
95
- natif) ou ``None`` (chemin chat/vision pour ``pixtral-*``).
96
- Centralisé pour que ``run()`` puisse extraire les
97
- ``token_confidences`` sans dupliquer la requête API.
98
- """
99
- if not self._api_key:
100
- raise RuntimeError(
101
- "Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY"
102
- )
103
-
104
- suffix = image_path.suffix.lower()
105
- media_type = {
106
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
107
- ".png": "image/png", ".tif": "image/tiff",
108
- ".tiff": "image/tiff", ".webp": "image/webp",
109
- }.get(suffix, "image/jpeg")
110
-
111
- image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
112
- image_url = f"data:{media_type};base64,{image_b64}"
113
-
114
- if "mistral-ocr" in self._model.lower():
115
- return self._run_ocr_native_api(image_url)
116
- return self._run_ocr_vision_api(image_url), None
117
-
118
- def _run_ocr_native_api(self, image_url: str) -> tuple[str, dict]:
119
- """Endpoint dédié /v1/ocr (pour mistral-ocr-latest et variantes).
120
-
121
- Retourne ``(text, raw_response_dict)`` pour permettre
122
- l'extraction des confidences en post-traitement.
123
- """
124
- import json
125
- import urllib.request
126
-
127
- payload = json.dumps({
128
- "model": self._model,
129
- "document": {"type": "image_url", "image_url": image_url},
130
- }).encode("utf-8")
131
- req = urllib.request.Request(
132
- "https://api.mistral.ai/v1/ocr",
133
- data=payload,
134
- headers={
135
- "Authorization": f"Bearer {self._api_key}",
136
- "Content-Type": "application/json",
137
- },
138
- method="POST",
139
- )
140
- with urllib.request.urlopen(req, timeout=60) as resp:
141
- data = json.loads(resp.read().decode())
142
- pages = data.get("pages", [])
143
- text = "\n\n".join(p.get("markdown", "") for p in pages).strip()
144
- return text, data
145
-
146
- def _run_ocr_vision_api(self, image_url: str) -> str:
147
- """API vision/chat Mistral (pour pixtral-12b, pixtral-large, etc.)."""
148
- try:
149
- try:
150
- from mistralai.client import Mistral
151
- except ImportError:
152
- from mistralai import Mistral # type: ignore[no-redef]
153
- except ImportError as exc:
154
- raise RuntimeError(
155
- "Le package 'mistralai' n'est pas installé. Lancez : pip install mistralai"
156
- ) from exc
157
-
158
- client = Mistral(api_key=self._api_key)
159
- response = client.chat.complete(
160
- model=self._model,
161
- messages=[
162
- {
163
- "role": "user",
164
- "content": [
165
- {"type": "text", "text": self._prompt},
166
- {"type": "image_url", "image_url": image_url},
167
- ],
168
- }
169
- ],
170
- max_tokens=self._max_tokens,
171
- )
172
- return response.choices[0].message.content or ""
173
-
174
- def _extract_raw_confidences(
175
- self, native: Any,
176
- ) -> Optional[list[dict[str, Any]]]:
177
- """Extrait les paires ``(token, confidence)`` de la réponse
178
- ``/v1/ocr`` quand elles existent.
179
-
180
- Mistral OCR peut exposer ``confidence`` à différents niveaux
181
- (page, block, line, word) selon le modèle. L'extracteur
182
- cherche dans les structures suivantes en cascade :
183
-
184
- 1. ``pages[i].words[j]`` avec ``{"text", "confidence"}``
185
- 2. ``pages[i].lines[j]`` avec ``{"text", "confidence"}`` →
186
- propage la confidence aux mots de la ligne (comme Pero OCR
187
- Sprint 48)
188
- 3. ``pages[i].blocks[j]`` avec ``{"text", "confidence"}`` →
189
- idem, propage à chaque mot
190
-
191
- Retourne ``None`` si aucun champ ``confidence`` exploitable
192
- n'est trouvé (cas le plus courant si l'API renvoie uniquement
193
- du markdown sans annotation, ou si on est sur le chemin
194
- chat/vision ``pixtral-*``).
195
- """
196
- if not self.config.get("expose_confidences", True):
197
- return None
198
- if not native or not isinstance(native, dict):
199
- return None
200
- out: list[dict[str, Any]] = []
201
- pages = native.get("pages") or []
202
- for page in pages:
203
- if not isinstance(page, dict):
204
- continue
205
- # Niveau 1 : words explicites
206
- for w in page.get("words") or []:
207
- self._maybe_emit_word(w, out)
208
- # Niveau 2 : lines avec confidence propagée
209
- for line in page.get("lines") or []:
210
- self._emit_lines_or_blocks(line, out)
211
- # Niveau 3 : blocks avec confidence propagée
212
- for block in page.get("blocks") or []:
213
- self._emit_lines_or_blocks(block, out)
214
- return out or None
215
-
216
- @staticmethod
217
- def _maybe_emit_word(word: Any, out: list) -> None:
218
- if not isinstance(word, dict):
219
- return
220
- text = (word.get("text") or "").strip()
221
- conf = word.get("confidence")
222
- if not text or conf is None:
223
- return
224
- out.append({"token": text, "confidence": conf})
225
-
226
- @staticmethod
227
- def _emit_lines_or_blocks(item: Any, out: list) -> None:
228
- """Pour une line/block, propage sa confidence à chaque mot."""
229
- if not isinstance(item, dict):
230
- return
231
- text = (item.get("text") or "").strip()
232
- conf = item.get("confidence")
233
- if not text or conf is None:
234
- return
235
- for word in text.split():
236
- if word:
237
- out.append({"token": word, "confidence": conf})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/pero_ocr.py DELETED
@@ -1,187 +0,0 @@
1
- """Adaptateur Pero OCR.
2
-
3
- Phase 7.A — module relocalisé depuis ``picarones.engines.pero_ocr``
4
- vers ``picarones.adapters.legacy_engines.pero_ocr``. Le chemin legacy
5
- reste disponible via un shim avec ``DeprecationWarning`` ;
6
- suppression prévue en 2.0.
7
-
8
- Pero OCR est un moteur HTR/OCR performant sur les documents historiques,
9
- développé par l'Université technologique de Brno.
10
-
11
- Dépendance : pero-ocr (pip install pero-ocr)
12
- Dépôt : https://github.com/DCGM/pero-ocr
13
-
14
- Configuration YAML :
15
- ```yaml
16
- name: pero_ocr
17
- engine: pero_ocr
18
- config: /chemin/vers/config.ini # fichier de configuration Pero OCR
19
- cuda: false # utiliser le GPU si disponible
20
- expose_confidences: true # défaut ; expose la confidence par ligne
21
- # (transcription_confidence) à tous les
22
- # mots de la ligne, format Sprint 42
23
- ```
24
-
25
- Sprint 48 — exposition des token_confidences
26
- ---------------------------------------------
27
- Pero OCR fournit ``line.transcription_confidence`` (probabilité moyenne CTC
28
- sur la ligne). L'adapter applique cette confidence à chaque **mot** de la
29
- ligne (granularité disponible la plus fine sans logits CTC).
30
-
31
- Refactor du chantier 1 (post-Sprint 97)
32
- ---------------------------------------
33
- L'adapter ne surcharge plus ``run()`` — il implémente ``_run_with_native``
34
- et ``_extract_raw_confidences`` (les hooks factorisés dans ``BaseOCREngine``).
35
- Comportement externe et octets de sortie strictement identiques.
36
- """
37
-
38
- from __future__ import annotations
39
-
40
- import logging
41
- from pathlib import Path
42
- from typing import Any, Optional
43
-
44
- from picarones.adapters.legacy_engines.base import BaseOCREngine
45
-
46
- try:
47
- import numpy as np
48
- from PIL import Image
49
-
50
- _PIL_AVAILABLE = True
51
- except ImportError:
52
- _PIL_AVAILABLE = False
53
-
54
- try:
55
- from pero_ocr.document_ocr.layout import PageLayout
56
- from pero_ocr.document_ocr.page_parser import PageParser
57
-
58
- _PERO_AVAILABLE = True
59
- except ImportError:
60
- _PERO_AVAILABLE = False
61
-
62
-
63
- logger = logging.getLogger(__name__)
64
-
65
-
66
- class PeroOCREngine(BaseOCREngine):
67
- """Adaptateur pour Pero OCR.
68
-
69
- Pero OCR produit une sortie structurée (PAGE XML) ; cet adaptateur
70
- en extrait le texte plat dans l'ordre de lecture naturel et, depuis
71
- le Sprint 48, les confidences au niveau mot (héritées de la
72
- confidence ligne ``transcription_confidence``).
73
-
74
- Moteur CPU-bound : utilise ``ProcessPoolExecutor`` dans le runner parallèle.
75
- """
76
-
77
- execution_mode = "cpu"
78
-
79
- def __init__(self, config: Optional[dict] = None) -> None:
80
- super().__init__(config)
81
- self._parser: Optional[object] = None
82
-
83
- @property
84
- def name(self) -> str:
85
- return self.config.get("name", "pero_ocr")
86
-
87
- def version(self) -> str:
88
- if not _PERO_AVAILABLE:
89
- raise RuntimeError("pero-ocr n'est pas installé.")
90
- try:
91
- import pero_ocr
92
-
93
- return getattr(pero_ocr, "__version__", "unknown")
94
- except Exception as exc: # noqa: BLE001
95
- logger.debug(
96
- "[pero_ocr] version non disponible : %s", exc, exc_info=True,
97
- )
98
- return "unknown"
99
-
100
- def _get_parser(self) -> "PageParser":
101
- """Instancie le PageParser (lazy, une seule fois par moteur)."""
102
- if self._parser is None:
103
- if not _PERO_AVAILABLE:
104
- raise RuntimeError(
105
- "pero-ocr n'est pas installé. "
106
- "Installez-le avec : pip install pero-ocr"
107
- )
108
- config_path = self.config.get("config")
109
- if not config_path:
110
- raise ValueError(
111
- "La configuration Pero OCR requiert un paramètre 'config' "
112
- "pointant vers un fichier .ini Pero OCR valide."
113
- )
114
- import configparser
115
-
116
- parser_config = configparser.ConfigParser()
117
- parser_config.read(config_path)
118
- self._parser = PageParser(parser_config)
119
- return self._parser # type: ignore[return-value]
120
-
121
- def _run_pero_pipeline(self, image_path: Path) -> tuple[str, Any]:
122
- """Exécute le pipeline Pero OCR et retourne ``(text, page_layout)``."""
123
- if not _PIL_AVAILABLE:
124
- raise RuntimeError("Pillow n'est pas installé.")
125
-
126
- parser = self._get_parser()
127
-
128
- image = np.array(Image.open(image_path).convert("RGB"))
129
- page_layout = PageLayout(id=image_path.stem, page_size=(image.shape[0], image.shape[1]))
130
-
131
- # Exécution du pipeline Pero OCR
132
- parser.process_page(image, page_layout)
133
-
134
- # Extraction du texte plat dans l'ordre des lignes
135
- lines = []
136
- for region in page_layout.regions:
137
- for line in region.lines:
138
- if line.transcription:
139
- lines.append(line.transcription.strip())
140
-
141
- return "\n".join(lines), page_layout
142
-
143
- def _run_ocr(self, image_path: Path) -> str:
144
- text, _ = self._run_pero_pipeline(image_path)
145
- return text
146
-
147
- def _run_with_native(self, image_path: Path) -> tuple[str, Any]:
148
- """Exécute Pero OCR et retourne ``(text, page_layout)``.
149
-
150
- Un seul passage du pipeline coûteux ; le ``page_layout``
151
- contient toutes les informations nécessaires à l'extraction
152
- des confidences (Sprint 48).
153
- """
154
- return self._run_pero_pipeline(image_path)
155
-
156
- def _extract_raw_confidences(
157
- self, native: Any,
158
- ) -> Optional[list[dict[str, Any]]]:
159
- """Extrait les confidences au niveau mot depuis ``page_layout``.
160
-
161
- Stratégie : pour chaque ligne, on prend
162
- ``line.transcription_confidence`` (probabilité CTC moyenne) et
163
- on l'applique à chaque mot de la ligne. Granularité minimale
164
- sans déchiffrer les logits CTC, mais suffisante pour la
165
- calibration.
166
- """
167
- if not self.config.get("expose_confidences", True):
168
- return None
169
- if native is None:
170
- return None
171
- out: list[dict[str, Any]] = []
172
- for region in getattr(native, "regions", []) or []:
173
- for line in getattr(region, "lines", []) or []:
174
- transcription = getattr(line, "transcription", None)
175
- if not transcription:
176
- continue
177
- conf = getattr(line, "transcription_confidence", None)
178
- if conf is None:
179
- continue
180
- for word in transcription.strip().split():
181
- if word:
182
- out.append({"token": word, "confidence": conf})
183
- return out or None
184
-
185
- @classmethod
186
- def from_config(cls, config: Optional[dict] = None) -> "PeroOCREngine":
187
- return cls(config=config or {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_engines/tesseract.py DELETED
@@ -1,183 +0,0 @@
1
- """Adaptateur Tesseract 5 via pytesseract.
2
-
3
- Phase 7.A — module relocalisé depuis ``picarones.engines.tesseract``
4
- vers ``picarones.adapters.legacy_engines.tesseract``. Le chemin legacy
5
- reste disponible via un shim avec ``DeprecationWarning`` ;
6
- suppression prévue en 2.0.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import logging
12
- from pathlib import Path
13
- from typing import Any, Optional
14
-
15
- from picarones.adapters.legacy_engines.base import BaseOCREngine
16
-
17
- try:
18
- import pytesseract
19
- from PIL import Image
20
-
21
- _PYTESSERACT_AVAILABLE = True
22
- except ImportError:
23
- _PYTESSERACT_AVAILABLE = False
24
-
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- # Correspondance des valeurs PSM acceptées en argument YAML/CLI
30
- _PSM_LABELS = {
31
- 0: "Orientation and script detection only",
32
- 1: "Automatic page segmentation with OSD",
33
- 3: "Fully automatic page segmentation (default)",
34
- 4: "Single column of text",
35
- 5: "Single uniform block of vertically aligned text",
36
- 6: "Single uniform block of text",
37
- 7: "Single text line",
38
- 8: "Single word",
39
- 9: "Single word in a circle",
40
- 10: "Single character",
41
- 11: "Sparse text",
42
- 12: "Sparse text with OSD",
43
- 13: "Raw line",
44
- }
45
-
46
-
47
- class TesseractEngine(BaseOCREngine):
48
- """Adaptateur pour Tesseract 5 (via pytesseract).
49
-
50
- Moteur CPU-bound : utilise ``ProcessPoolExecutor`` dans le runner parallèle.
51
-
52
- Configuration YAML :
53
- ```yaml
54
- name: tesseract
55
- engine: tesseract
56
- lang: fra # code langue Tesseract (fra, lat, eng, ...)
57
- psm: 6 # Page Segmentation Mode (0-13)
58
- oem: 3 # OCR Engine Mode (0=legacy, 3=LSTM, 3=default)
59
- tesseract_cmd: tesseract # chemin vers l'exécutable si non standard
60
- expose_confidences: true # défaut ; mettre à false pour économiser
61
- # un appel image_to_data par document
62
- ```
63
-
64
- Sprint 47 — exposition des token_confidences
65
- --------------------------------------------
66
- L'adapter appelle ``image_to_data`` en parallèle de
67
- ``image_to_string`` pour produire ``EngineResult.token_confidences``
68
- (liste de ``{"token": str, "confidence": float}``). Le runner
69
- Sprint 42 calcule alors automatiquement la calibration ECE/MCE.
70
-
71
- Le texte ``EngineResult.text`` reste **strictement identique** à
72
- celui produit par ``image_to_string`` (pas de reconstruction depuis
73
- ``image_to_data``) — rétrocompatibilité octet par octet.
74
-
75
- Le coût supplémentaire est d'un second appel Tesseract par image.
76
- Pour le désactiver : ``expose_confidences: false`` dans la config.
77
-
78
- Refactor du chantier 1 (post-Sprint 97)
79
- ---------------------------------------
80
- L'adapter ne surcharge plus ``run()`` — il implémente
81
- ``_run_with_native`` et ``_extract_raw_confidences`` (les hooks
82
- factorisés dans ``BaseOCREngine``). Comportement externe et
83
- octets de sortie strictement identiques aux versions Sprint 47+.
84
- """
85
-
86
- execution_mode = "cpu"
87
-
88
- @property
89
- def name(self) -> str:
90
- return self.config.get("name", "tesseract")
91
-
92
- def version(self) -> str:
93
- if not _PYTESSERACT_AVAILABLE:
94
- raise RuntimeError("pytesseract n'est pas installé.")
95
- return pytesseract.get_tesseract_version().vstring
96
-
97
- def _tesseract_args(self) -> tuple[str, str]:
98
- """Retourne ``(lang, custom_config)`` selon la config courante.
99
-
100
- Centralisé pour rester cohérent entre ``_run_ocr`` et
101
- ``_run_with_native``.
102
- """
103
- lang = self.config.get("lang", "fra")
104
- psm = int(self.config.get("psm", 6))
105
- oem = int(self.config.get("oem", 3))
106
- return lang, f"--oem {oem} --psm {psm}"
107
-
108
- def _apply_tesseract_cmd(self) -> None:
109
- """Applique le chemin Tesseract custom si la config en fournit un."""
110
- tesseract_cmd = self.config.get("tesseract_cmd")
111
- if tesseract_cmd:
112
- pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
113
-
114
- def _run_ocr(self, image_path: Path) -> str:
115
- if not _PYTESSERACT_AVAILABLE:
116
- raise RuntimeError(
117
- "pytesseract n'est pas installé. "
118
- "Installez-le avec : pip install pytesseract"
119
- )
120
-
121
- self._apply_tesseract_cmd()
122
- lang, custom_config = self._tesseract_args()
123
- image = Image.open(image_path)
124
- text: str = pytesseract.image_to_string(image, lang=lang, config=custom_config)
125
- return text.strip()
126
-
127
- def _run_with_native(self, image_path: Path) -> tuple[str, Optional[dict]]:
128
- """Appelle ``image_to_string`` puis ``image_to_data``.
129
-
130
- Retourne ``(text, image_to_data_dict)`` — la deuxième valeur
131
- peut être ``None`` si ``expose_confidences`` est à ``False``
132
- ou si l'appel ``image_to_data`` échoue (best-effort).
133
-
134
- Le texte reste **identique** à celui produit par
135
- ``_run_ocr`` (rétrocompat octet par octet — Sprint 47).
136
- """
137
- text = self._run_ocr(image_path)
138
- if not self.config.get("expose_confidences", True):
139
- return text, None
140
- try:
141
- self._apply_tesseract_cmd()
142
- lang, custom_config = self._tesseract_args()
143
- image = Image.open(image_path)
144
- data = pytesseract.image_to_data(
145
- image,
146
- lang=lang,
147
- config=custom_config,
148
- output_type=pytesseract.Output.DICT,
149
- )
150
- return text, data
151
- except Exception as exc: # noqa: BLE001
152
- logger.warning(
153
- "[tesseract] extraction des token_confidences "
154
- "(image_to_data) indisponible : %s — calibration "
155
- "sautée pour ce document",
156
- exc,
157
- )
158
- return text, None
159
-
160
- def _extract_raw_confidences(
161
- self, native: Any,
162
- ) -> Optional[list[dict[str, Any]]]:
163
- """Parse le ``image_to_data`` dict de Tesseract.
164
-
165
- Format Tesseract : dict ``{"text": [...], "conf": [...], ...}``
166
- avec confidences ∈ [0, 100] et ``-1`` pour les segments
167
- non-mots — ces derniers sont écartés par
168
- ``_normalize_token_confidences`` (filtre les conf < 0).
169
- """
170
- if not isinstance(native, dict):
171
- return None
172
- texts = native.get("text") or []
173
- confs = native.get("conf") or []
174
- if not texts or len(texts) != len(confs):
175
- return None
176
- out: list[dict[str, Any]] = []
177
- for tok_text, conf in zip(texts, confs):
178
- out.append({"token": tok_text, "confidence": conf})
179
- return out or None
180
-
181
- @classmethod
182
- def from_config(cls, config: Optional[dict] = None) -> "TesseractEngine":
183
- return cls(config=config or {})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_pipelines/__init__.py DELETED
@@ -1,34 +0,0 @@
1
- """Pipelines OCR+LLM legacy — Sprint C du plan v2.0 (mai 2026).
2
-
3
- Sous-package transitoire qui contient ``OCRLLMPipeline`` (legacy)
4
- et son helper ``_executor_runner``. Pendant la phase de retrait
5
- du legacy, ces modules vivent ici plutôt que dans
6
- ``picarones.pipelines/`` (top-level) pour respecter l'invariant
7
- architectural ``test_layer_imports_are_legal`` — la couche
8
- ``adapters/`` autorise les imports legacy par design.
9
-
10
- Périmètre
11
- ---------
12
- - ``base.OCRLLMPipeline`` — wrapper composé OCR+LLM (3 modes).
13
- Délègue à ``picarones.pipeline.PipelineExecutor`` depuis
14
- Sprint B du plan v2.0.
15
- - ``_executor_runner.run_pipeline_via_executor`` — pont
16
- mono-document utilisé par ``OCRLLMPipeline.run()``.
17
-
18
- Trace de retrait
19
- ----------------
20
- Ce sous-package sera supprimé entièrement quand
21
- ``OCRLLMPipeline`` n'aura plus aucun consommateur externe (les
22
- callers actuels — ``web/benchmark_utils.py``, tests Sprint 3 et
23
- 15 — passeront alors à la construction directe d'une
24
- ``PipelineSpec`` via ``picarones.pipeline.make_ocr_llm_pipeline_spec``).
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- from picarones.adapters.legacy_pipelines.base import (
30
- OCRLLMPipeline,
31
- PipelineMode,
32
- )
33
-
34
- __all__ = ["OCRLLMPipeline", "PipelineMode"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_pipelines/_executor_runner.py DELETED
@@ -1,410 +0,0 @@
1
- """Helper d'exécution mono-document via ``PipelineExecutor`` (Sprint B).
2
-
3
- Sprint B du plan v2.0 — pont entre l'API mono-document
4
- ``OCRLLMPipeline.run(image_path) -> EngineResult`` (legacy) et le
5
- ``PipelineExecutor`` du rewrite.
6
-
7
- Ce helper isole toute la plomberie nécessaire pour exécuter une
8
- ``PipelineSpec`` sur un seul document avec :
9
-
10
- - création d'un ``tempdir`` éphémère comme ``workspace_uri`` ;
11
- - adapter resolver minimal qui mappe les noms de la spec aux
12
- instances OCR/LLM portées par le ``OCRLLMPipeline`` ;
13
- - conversion du ``PipelineResult`` en ``EngineResult`` legacy ;
14
- - préservation des warnings comportementaux du legacy
15
- (texte OCR vide, texte LLM vide, erreur pipeline globale).
16
-
17
- Trace de retrait
18
- ----------------
19
- Ce module est temporaire (Sprint B-D du plan v2.0). Il sera
20
- supprimé en Sprint C quand les 3 callers (``web/benchmark_utils``,
21
- ``measurements/runner/orchestration``, ``fixtures``) consommeront
22
- des ``PipelineSpec`` directement plutôt que des ``OCRLLMPipeline``.
23
- """
24
-
25
- from __future__ import annotations
26
-
27
- import logging
28
- import tempfile
29
- import time
30
- from pathlib import Path
31
- from typing import TYPE_CHECKING, Any, Optional
32
-
33
- from picarones.adapters.legacy_engines._step_executor import (
34
- LegacyOCREngineExecutor,
35
- )
36
- from picarones.adapters.legacy_engines.base import EngineResult
37
- from picarones.domain.artifacts import Artifact, ArtifactType
38
- from picarones.domain.documents import DocumentRef
39
- from picarones.domain.pipeline_spec import (
40
- INITIAL_STEP_ID,
41
- PipelineSpec,
42
- PipelineStep,
43
- )
44
- from picarones.pipeline import (
45
- PipelineExecutor,
46
- RunContext,
47
- make_ocr_llm_pipeline_spec,
48
- )
49
-
50
- if TYPE_CHECKING:
51
- from picarones.adapters.legacy_pipelines.base import OCRLLMPipeline
52
-
53
-
54
- logger = logging.getLogger("picarones.pipelines.base")
55
-
56
-
57
- def run_pipeline_via_executor(
58
- pipeline: "OCRLLMPipeline",
59
- image_path: Path,
60
- *,
61
- ocr_text: Optional[str] = None,
62
- ) -> EngineResult:
63
- """Exécute une chaîne OCR+LLM via ``PipelineExecutor``.
64
-
65
- Cas 1 — ``ocr_text=None`` (run() classique) :
66
- Modes ``text_only`` / ``text_and_image`` / ``zero_shot``.
67
- La spec a un step OCR (sauf zero-shot) + un step LLM.
68
-
69
- Cas 2 — ``ocr_text`` fourni (run_with_ocr_text, corpus triplet) :
70
- Le texte OCR est pré-calculé. La spec n'a qu'un step LLM
71
- qui consomme ``RAW_TEXT`` directement depuis les inputs
72
- initiaux (pas d'OCR engine appelé).
73
-
74
- Parameters
75
- ----------
76
- pipeline:
77
- L'instance ``OCRLLMPipeline`` qui porte ``ocr_engine``,
78
- ``llm_adapter``, ``mode`` et ``_prompt_template``.
79
- image_path:
80
- Chemin de l'image à transcrire.
81
- ocr_text:
82
- Si fourni, mode "post-correction" — le LLM reçoit ce texte
83
- directement, sans appel OCR.
84
-
85
- Returns
86
- -------
87
- EngineResult
88
- Format legacy compatible avec ``BaseOCREngine.run()``. Les
89
- métadonnées portent ``pipeline_mode``, ``pipeline_steps``,
90
- ``llm_model``, ``llm_provider``, ``ocr_intermediate``,
91
- ``is_pipeline=True`` etc.
92
- """
93
- start = time.perf_counter()
94
-
95
- # Le LLM peut être un BaseLLMAdapter ou un BaseVLMAdapter — les
96
- # deux exposent .name et .model. On compose un identifiant
97
- # ``provider:model`` stable pour le adapter resolver.
98
- llm_name = f"{pipeline.llm_adapter.name}:{pipeline.llm_adapter.model}"
99
-
100
- with tempfile.TemporaryDirectory(prefix="picarones_pipe_") as ws:
101
- workspace = Path(ws)
102
-
103
- # ── Construit la spec adaptée au cas (avec ou sans OCR)
104
- if ocr_text is None:
105
- spec, ocr_step_executor = _build_spec_for_run(
106
- pipeline=pipeline,
107
- llm_name=llm_name,
108
- )
109
- initial_inputs = {
110
- ArtifactType.IMAGE: _make_image_artifact(image_path, "doc"),
111
- }
112
- else:
113
- spec, ocr_step_executor = _build_spec_for_run_with_ocr_text(
114
- pipeline=pipeline,
115
- llm_name=llm_name,
116
- )
117
- # Écrire le texte OCR pré-fourni dans le workspace pour
118
- # qu'il soit accessible via Artifact.uri.
119
- text_path = workspace / "ocr_input.txt"
120
- text_path.write_text(ocr_text, encoding="utf-8")
121
- initial_inputs = {
122
- ArtifactType.IMAGE: _make_image_artifact(image_path, "doc"),
123
- ArtifactType.RAW_TEXT: Artifact(
124
- id="doc:initial:raw_text",
125
- document_id="doc",
126
- type=ArtifactType.RAW_TEXT,
127
- uri=str(text_path),
128
- ),
129
- }
130
-
131
- # ── Adapter resolver — mappe les noms de la spec aux instances
132
- def resolver(name: str) -> Any:
133
- if ocr_step_executor is not None and (
134
- pipeline.ocr_engine is not None
135
- and name == pipeline.ocr_engine.name
136
- ):
137
- return ocr_step_executor
138
- if name == llm_name:
139
- return pipeline.llm_adapter
140
- raise KeyError(f"adapter inconnu pour la spec : {name!r}")
141
-
142
- document = DocumentRef(id="doc", image_uri=str(image_path))
143
- context = RunContext(
144
- document_id="doc",
145
- code_version=_safe_code_version(),
146
- pipeline_name=spec.name,
147
- workspace_uri=str(workspace),
148
- )
149
-
150
- executor = PipelineExecutor(adapter_resolver=resolver)
151
- try:
152
- result = executor.run(spec, document, initial_inputs, context)
153
- error: Optional[str] = None
154
- except Exception as exc: # noqa: BLE001
155
- logger.warning(
156
- "[%s] erreur pipeline pour '%s' : %s",
157
- pipeline.name, image_path.name, exc,
158
- )
159
- return _engine_result_failure(
160
- pipeline=pipeline,
161
- image_path=image_path,
162
- error=str(exc),
163
- duration=time.perf_counter() - start,
164
- ocr_text=ocr_text,
165
- )
166
-
167
- # ── Récupère le texte final depuis le bag d'artifacts
168
- text, ocr_intermediate = _extract_outputs(
169
- result=result,
170
- mode=pipeline.mode.value,
171
- ocr_text=ocr_text,
172
- )
173
-
174
- # ── Préserve les warnings comportementaux du legacy
175
- if ocr_text is None and pipeline.mode.value != "zero_shot":
176
- if ocr_intermediate is not None and not ocr_intermediate.strip():
177
- logger.warning(
178
- "[%s] texte OCR vide pour '%s' — le LLM recevra "
179
- "{ocr_output} vide.",
180
- pipeline.name, image_path.name,
181
- )
182
- if not text or not text.strip():
183
- logger.warning(
184
- "[%s] le LLM ('%s') a retourné un texte vide pour '%s'. "
185
- "CER sera calculé à 1.0 (100%%). "
186
- "Vérifier : (1) le prompt contient-il {ocr_output} ? "
187
- "(2) le modèle supporte-t-il ce mode d'appel ? "
188
- "(3) la réponse n'est-elle pas tronquée (max_tokens) ?",
189
- pipeline.name, pipeline.llm_adapter.model, image_path.name,
190
- )
191
-
192
- # ── Si le pipeline a échoué (un step en error), on traduit
193
- # l'erreur du premier step en échec en EngineResult.error.
194
- if not result.succeeded:
195
- failed_step = next(
196
- (s for s in result.step_results if s.error is not None),
197
- None,
198
- )
199
- error = failed_step.error if failed_step is not None else "pipeline failed"
200
-
201
- duration = time.perf_counter() - start
202
-
203
- metadata = _build_metadata(
204
- pipeline=pipeline,
205
- ocr_intermediate=ocr_intermediate,
206
- ocr_source="corpus" if ocr_text is not None else None,
207
- )
208
-
209
- return EngineResult(
210
- engine_name=pipeline.name,
211
- image_path=str(image_path),
212
- text=text if text else "",
213
- duration_seconds=round(duration, 4),
214
- error=error,
215
- metadata=metadata,
216
- )
217
-
218
-
219
- # ──────────────────────────────────────────────────────────────────────
220
- # Helpers privés
221
- # ──────────────────────────────────────────────────────────────────────
222
-
223
-
224
- def _build_spec_for_run(
225
- pipeline: "OCRLLMPipeline",
226
- llm_name: str,
227
- ) -> tuple[PipelineSpec, Optional[LegacyOCREngineExecutor]]:
228
- """Spec pour ``run()`` — mode text_only / text_and_image / zero_shot."""
229
- mode = pipeline.mode.value
230
- llm_params = {"prompt_template": pipeline._prompt_template}
231
-
232
- if mode == "zero_shot":
233
- spec = make_ocr_llm_pipeline_spec(
234
- mode="zero_shot",
235
- llm_adapter_name=llm_name,
236
- llm_params=llm_params,
237
- )
238
- return spec, None
239
-
240
- if pipeline.ocr_engine is None:
241
- raise ValueError(
242
- f"ocr_engine est requis pour le mode {mode!r} — "
243
- "utiliser run_with_ocr_text() pour la post-correction sans engine."
244
- )
245
- ocr_step = LegacyOCREngineExecutor(pipeline.ocr_engine)
246
- spec = make_ocr_llm_pipeline_spec(
247
- mode=mode,
248
- ocr_adapter_name=pipeline.ocr_engine.name,
249
- llm_adapter_name=llm_name,
250
- llm_params=llm_params,
251
- )
252
- return spec, ocr_step
253
-
254
-
255
- def _build_spec_for_run_with_ocr_text(
256
- pipeline: "OCRLLMPipeline",
257
- llm_name: str,
258
- ) -> tuple[PipelineSpec, None]:
259
- """Spec pour ``run_with_ocr_text()`` — 1 seul step LLM, RAW_TEXT
260
- et IMAGE viennent des inputs initiaux."""
261
- mode = pipeline.mode.value
262
- llm_params = {"prompt_template": pipeline._prompt_template}
263
-
264
- llm_input_types: list[ArtifactType] = [ArtifactType.RAW_TEXT]
265
- llm_inputs_from: dict[ArtifactType, str] = {
266
- ArtifactType.RAW_TEXT: INITIAL_STEP_ID,
267
- }
268
- if mode == "text_and_image":
269
- llm_input_types.append(ArtifactType.IMAGE)
270
- llm_inputs_from[ArtifactType.IMAGE] = INITIAL_STEP_ID
271
-
272
- spec = PipelineSpec(
273
- name=f"post_correction_{mode}_{_safe_name_for_id(llm_name)}",
274
- description=(
275
- f"Post-correction LLM mono-step (mode {mode}, "
276
- f"texte OCR pré-fourni)"
277
- ),
278
- initial_inputs=(ArtifactType.IMAGE, ArtifactType.RAW_TEXT),
279
- steps=(
280
- PipelineStep(
281
- id="llm",
282
- kind="post_correction",
283
- adapter_name=llm_name,
284
- params=llm_params,
285
- input_types=tuple(llm_input_types),
286
- output_types=(ArtifactType.CORRECTED_TEXT,),
287
- inputs_from=llm_inputs_from,
288
- ),
289
- ),
290
- )
291
- return spec, None
292
-
293
-
294
- def _make_image_artifact(image_path: Path, doc_id: str) -> Artifact:
295
- return Artifact(
296
- id=f"{doc_id}:initial:image",
297
- document_id=doc_id,
298
- type=ArtifactType.IMAGE,
299
- uri=str(image_path),
300
- )
301
-
302
-
303
- def _extract_outputs(
304
- *,
305
- result: Any,
306
- mode: str,
307
- ocr_text: Optional[str],
308
- ) -> tuple[str, Optional[str]]:
309
- """Extrait ``(text_final, ocr_intermediate)`` du PipelineResult.
310
-
311
- En zero_shot : le VLM produit ``RAW_TEXT`` final. Pas
312
- d'``ocr_intermediate``.
313
-
314
- En text_only / text_and_image : le LLM produit ``CORRECTED_TEXT``.
315
- L'``ocr_intermediate`` est l'``RAW_TEXT`` produit par l'OCR ou
316
- fourni via ``ocr_text`` (mode triplet).
317
- """
318
- text_final = ""
319
- ocr_intermediate: Optional[str] = ocr_text
320
-
321
- if mode == "zero_shot":
322
- # Le step VLM produit RAW_TEXT en sortie finale.
323
- for art in result.artifacts:
324
- if art.type == ArtifactType.RAW_TEXT and art.uri:
325
- text_final = Path(art.uri).read_text(encoding="utf-8")
326
- break
327
- return text_final, None
328
-
329
- # text_only / text_and_image : prendre CORRECTED_TEXT
330
- for art in result.artifacts:
331
- if art.type == ArtifactType.CORRECTED_TEXT and art.uri:
332
- text_final = Path(art.uri).read_text(encoding="utf-8")
333
- break
334
-
335
- # ocr_intermediate : si pas fourni, lire le RAW_TEXT produit
336
- if ocr_intermediate is None:
337
- for art in result.artifacts:
338
- if art.type == ArtifactType.RAW_TEXT and art.uri:
339
- ocr_intermediate = Path(art.uri).read_text(encoding="utf-8")
340
- break
341
-
342
- return text_final, ocr_intermediate
343
-
344
-
345
- def _build_metadata(
346
- *,
347
- pipeline: "OCRLLMPipeline",
348
- ocr_intermediate: Optional[str],
349
- ocr_source: Optional[str],
350
- ) -> dict:
351
- metadata: dict = {
352
- "engine_version": pipeline._safe_version(),
353
- "pipeline_mode": pipeline.mode.value,
354
- "prompt_file": pipeline.prompt_path,
355
- "prompt_template": pipeline._prompt_template,
356
- "llm_model": pipeline.llm_adapter.model,
357
- "llm_provider": pipeline.llm_adapter.name,
358
- "pipeline_steps": pipeline._build_steps_info(),
359
- "is_pipeline": True,
360
- }
361
- if ocr_intermediate is not None:
362
- metadata["ocr_intermediate"] = ocr_intermediate
363
- if ocr_source is not None:
364
- metadata["ocr_source"] = ocr_source
365
- return metadata
366
-
367
-
368
- def _engine_result_failure(
369
- *,
370
- pipeline: "OCRLLMPipeline",
371
- image_path: Path,
372
- error: str,
373
- duration: float,
374
- ocr_text: Optional[str],
375
- ) -> EngineResult:
376
- """Construit un ``EngineResult`` en échec quand l'executor lève."""
377
- metadata = _build_metadata(
378
- pipeline=pipeline,
379
- ocr_intermediate=ocr_text,
380
- ocr_source="corpus" if ocr_text is not None else None,
381
- )
382
- return EngineResult(
383
- engine_name=pipeline.name,
384
- image_path=str(image_path),
385
- text="",
386
- duration_seconds=round(duration, 4),
387
- error=error,
388
- metadata=metadata,
389
- )
390
-
391
-
392
- def _safe_code_version() -> str:
393
- try:
394
- from picarones import __version__
395
- return __version__
396
- except ImportError:
397
- return "unknown"
398
-
399
-
400
- def _safe_name_for_id(s: str) -> str:
401
- return (
402
- s.replace(":", "_")
403
- .replace("/", "_")
404
- .replace("-", "_")
405
- .replace(".", "_")
406
- .lower()
407
- )
408
-
409
-
410
- __all__ = ["run_pipeline_via_executor"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/adapters/legacy_pipelines/base.py DELETED
@@ -1,338 +0,0 @@
1
- """Pipeline OCR+LLM — présenté comme un concurrent normal dans les benchmarks.
2
-
3
- Un pipeline compose un moteur OCR et un LLM de correction selon trois modes :
4
-
5
- text_only → OCR brut ──► LLM (texte seul)
6
- text_and_image → OCR brut + image ──► LLM multimodal
7
- zero_shot → image ──► LLM (pas d'OCR amont)
8
-
9
- La classe ``OCRLLMPipeline`` étend ``BaseOCREngine`` : un pipeline est
10
- un concurrent comme un autre dans ``run_benchmark``, avec les mêmes métriques
11
- CER/WER. Les métadonnées spécifiques (étapes, prompt, OCR intermédiaire) sont
12
- exposées via ``EngineResult.metadata``.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import base64
18
- import logging
19
- from enum import Enum
20
- from pathlib import Path
21
- from typing import Optional
22
-
23
- from picarones.adapters.legacy_engines.base import BaseOCREngine, EngineResult
24
- from picarones.adapters.llm.base import BaseLLMAdapter
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- class PipelineMode(str, Enum):
30
- """Mode d'appel LLM dans le pipeline."""
31
-
32
- TEXT_ONLY = "text_only"
33
- """Le LLM reçoit uniquement le texte OCR brut."""
34
-
35
- TEXT_AND_IMAGE = "text_and_image"
36
- """Le LLM reçoit le texte OCR ET l'image (mode multimodal)."""
37
-
38
- ZERO_SHOT = "zero_shot"
39
- """Le LLM reçoit uniquement l'image — aucun OCR amont."""
40
-
41
-
42
- # Répertoire de la bibliothèque de prompts intégrée.
43
- # Sprint C du plan v2.0 : ce module a quitté ``picarones/pipelines/``
44
- # pour ``picarones/adapters/legacy_pipelines/``. Le répertoire des
45
- # prompts vit toujours dans ``picarones/prompts/`` (top-level), donc
46
- # 3 niveaux au-dessus du ``__file__`` actuel.
47
- _PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "prompts"
48
-
49
-
50
- def _load_prompt(prompt_path: str | Path) -> str:
51
- """Charge un prompt depuis un chemin absolu, relatif ou depuis la bibliothèque intégrée."""
52
- p = Path(prompt_path)
53
- if p.is_absolute() and p.exists():
54
- return p.read_text(encoding="utf-8")
55
- # Chemin relatif : chercher d'abord dans le CWD, puis dans la bibliothèque
56
- if p.exists():
57
- return p.read_text(encoding="utf-8")
58
- builtin = _PROMPTS_DIR / p
59
- if builtin.exists():
60
- return builtin.read_text(encoding="utf-8")
61
- raise FileNotFoundError(
62
- f"Prompt introuvable : '{prompt_path}'. "
63
- f"Bibliothèque disponible dans : {_PROMPTS_DIR}"
64
- )
65
-
66
-
67
- def _image_to_b64(image_path: Path) -> str:
68
- """Encode une image en base64 pur (sans préfixe data URI)."""
69
- return base64.b64encode(image_path.read_bytes()).decode("ascii")
70
-
71
-
72
- class OCRLLMPipeline(BaseOCREngine):
73
- """Pipeline OCR+LLM, interchangeable avec n'importe quel moteur OCR.
74
-
75
- Parameters
76
- ----------
77
- llm_adapter:
78
- Adaptateur LLM (OpenAI, Anthropic, Mistral, Ollama…).
79
- mode:
80
- Mode de correction — text_only, text_and_image, ou zero_shot.
81
- prompt:
82
- Chemin vers un fichier .txt de prompt, ou nom d'un fichier de la
83
- bibliothèque intégrée (ex : ``"correction_medieval_french.txt"``).
84
- Variables disponibles dans le fichier : ``{ocr_output}`` et ``{image_b64}``.
85
- ocr_engine:
86
- Moteur OCR amont. Obligatoire pour text_only et text_and_image.
87
- Non utilisé en mode zero_shot.
88
- pipeline_name:
89
- Nom affiché dans le rapport (ex : ``"tesseract → gpt-4o"``).
90
- Généré automatiquement si non fourni.
91
- config:
92
- Paramètres supplémentaires passés à la classe de base.
93
-
94
- Examples
95
- --------
96
- >>> from picarones.adapters.llm import OpenAIAdapter
97
- >>> from picarones.adapters.legacy_engines.tesseract import TesseractEngine
98
- >>> pipeline = OCRLLMPipeline(
99
- ... ocr_engine=TesseractEngine({"lang": "fra"}),
100
- ... llm_adapter=OpenAIAdapter(model="gpt-4o"),
101
- ... mode=PipelineMode.TEXT_AND_IMAGE,
102
- ... prompt="correction_medieval_french.txt",
103
- ... )
104
- """
105
-
106
- def __init__(
107
- self,
108
- llm_adapter: BaseLLMAdapter,
109
- mode: PipelineMode | str = PipelineMode.TEXT_ONLY,
110
- prompt: str | Path = "correction_medieval_french.txt",
111
- ocr_engine: Optional[BaseOCREngine] = None,
112
- pipeline_name: Optional[str] = None,
113
- config: Optional[dict] = None,
114
- ) -> None:
115
- super().__init__(config)
116
- self.ocr_engine = ocr_engine
117
- self.llm_adapter = llm_adapter
118
- self.mode = PipelineMode(mode)
119
- self.prompt_path = str(prompt)
120
- self._prompt_template = _load_prompt(prompt)
121
-
122
- # Nom affiché dans le rapport
123
- if pipeline_name:
124
- self._name = pipeline_name
125
- elif self.mode == PipelineMode.ZERO_SHOT:
126
- self._name = f"{llm_adapter.model} (zero-shot)"
127
- elif ocr_engine:
128
- self._name = f"{ocr_engine.name} → {llm_adapter.model}"
129
- else:
130
- self._name = f"pipeline → {llm_adapter.model}"
131
-
132
-
133
- # ------------------------------------------------------------------
134
- # Interface BaseOCREngine
135
- # ------------------------------------------------------------------
136
-
137
- #: Sprint C du plan v2.0 : marqueur polymorphe que le runner
138
- #: utilise pour ajouter ``pipeline_steps`` + ``prompt_template``
139
- #: aux ``EngineReport.pipeline_info`` sans avoir à connaître le
140
- #: type concret ``OCRLLMPipeline``.
141
- is_pipeline: bool = True
142
-
143
- @property
144
- def name(self) -> str:
145
- return self._name
146
-
147
- def version(self) -> str:
148
- ocr_v = self.ocr_engine._safe_version() if self.ocr_engine else "—"
149
- return f"ocr={ocr_v}; llm={self.llm_adapter.model}"
150
-
151
- @property
152
- def pipeline_steps_info(self) -> list[dict]:
153
- """Description structurée des étapes (Sprint C — API publique).
154
-
155
- Substitut public à ``_build_steps_info()`` pour les callers
156
- externes (notamment le runner) qui ont besoin de connaître la
157
- composition de la pipeline pour la metadata du rapport.
158
- """
159
- return self._build_steps_info()
160
-
161
- @property
162
- def prompt_template(self) -> str:
163
- """Template de prompt courant (Sprint C — API publique)."""
164
- return self._prompt_template
165
-
166
- def _run_llm_step(
167
- self, image_path: Path, ocr_text: str,
168
- ) -> tuple[str, Optional[str]]:
169
- """Étape LLM du pipeline (commune à run() et run_with_ocr_text()).
170
-
171
- Construit le prompt, appelle le LLM, retourne ``(llm_text, ocr_intermediate)``.
172
- ``ocr_intermediate`` est ``None`` en mode zero_shot.
173
- """
174
- if self.mode == PipelineMode.ZERO_SHOT:
175
- image_b64 = _image_to_b64(image_path)
176
- prompt = self._build_prompt(image_b64=image_b64)
177
- logger.info("[Pipeline] appel LLM pour doc %s (zero-shot)", image_path.name)
178
- result = self.llm_adapter.complete(prompt, image_b64=image_b64)
179
-
180
- elif self.mode == PipelineMode.TEXT_ONLY:
181
- if not ocr_text.strip():
182
- logger.warning(
183
- "[%s] texte OCR vide pour '%s' — le LLM recevra {ocr_output} vide.",
184
- self._name, image_path.name,
185
- )
186
- prompt = self._build_prompt(ocr_text=ocr_text)
187
- logger.info(
188
- "[Pipeline] appel LLM pour doc %s (text_only, ocr=%d chars)",
189
- image_path.name, len(ocr_text),
190
- )
191
- result = self.llm_adapter.complete(prompt)
192
-
193
- else: # TEXT_AND_IMAGE
194
- if not ocr_text.strip():
195
- logger.warning(
196
- "[%s] texte OCR vide pour '%s' — le LLM recevra {ocr_output} vide.",
197
- self._name, image_path.name,
198
- )
199
- image_b64 = _image_to_b64(image_path)
200
- prompt = self._build_prompt(ocr_text=ocr_text, image_b64=image_b64)
201
- logger.info(
202
- "[Pipeline] appel LLM pour doc %s (text_and_image, ocr=%d chars)",
203
- image_path.name, len(ocr_text),
204
- )
205
- result = self.llm_adapter.complete(prompt, image_b64=image_b64)
206
-
207
- logger.info("[Pipeline] LLM retourné pour doc %s", image_path.name)
208
-
209
- if not result.success:
210
- raise RuntimeError(f"Erreur LLM ({self.llm_adapter.model}): {result.error}")
211
-
212
- llm_text = result.text
213
- logger.info(
214
- "[Pipeline] %s — OCR: %d chars → LLM: %d chars",
215
- image_path.name, len(ocr_text), len(llm_text),
216
- )
217
- if not llm_text or not llm_text.strip():
218
- logger.warning(
219
- "[%s] le LLM ('%s') a retourné un texte vide pour '%s'. "
220
- "CER sera calculé à 1.0 (100%%). "
221
- "Vérifier : (1) le prompt contient-il {ocr_output} ? "
222
- "(2) le modèle supporte-t-il ce mode d'appel ? "
223
- "(3) la réponse n'est-elle pas tronquée (max_tokens) ?",
224
- self._name, self.llm_adapter.model, image_path.name,
225
- )
226
- else:
227
- logger.debug(
228
- "[%s] réponse LLM : %d car., extrait : %r",
229
- self._name, len(llm_text), llm_text[:120],
230
- )
231
-
232
- ocr_intermediate = ocr_text if self.mode != PipelineMode.ZERO_SHOT else None
233
- return llm_text, ocr_intermediate
234
-
235
- def _run_ocr(self, image_path: Path) -> tuple[str, Optional[str]]:
236
- """Logique interne du pipeline — lance l'OCR engine puis le LLM.
237
-
238
- Returns
239
- -------
240
- tuple[str, Optional[str]]
241
- (llm_text, ocr_intermediate) — ocr_intermediate est None en mode zero_shot.
242
- """
243
- ocr_text = ""
244
- if self.mode != PipelineMode.ZERO_SHOT:
245
- if self.ocr_engine is None:
246
- raise ValueError(
247
- f"ocr_engine est requis pour le mode {self.mode.value} "
248
- "(utilisez run_with_ocr_text() pour la post-correction sans OCR engine)"
249
- )
250
- ocr_result = self.ocr_engine.run(image_path)
251
- ocr_text = ocr_result.text
252
-
253
- return self._run_llm_step(image_path, ocr_text)
254
-
255
- # ------------------------------------------------------------------
256
- # Override run() pour injecter les métadonnées pipeline
257
- # ------------------------------------------------------------------
258
-
259
- def run(self, image_path: str | Path) -> EngineResult:
260
- """Exécute le pipeline et retourne un EngineResult enrichi de métadonnées.
261
-
262
- Sprint B du plan v2.0 — délègue à
263
- ``picarones.pipelines._executor_runner.run_pipeline_via_executor``
264
- qui exécute la chaîne OCR+LLM via le ``PipelineExecutor`` du
265
- rewrite. L'API publique (``EngineResult`` retourné, métadonnées,
266
- warnings) reste identique au comportement historique.
267
- """
268
- from picarones.adapters.legacy_pipelines._executor_runner import (
269
- run_pipeline_via_executor,
270
- )
271
-
272
- return run_pipeline_via_executor(self, Path(image_path))
273
-
274
- # ------------------------------------------------------------------
275
- # Post-correction avec OCR pré-calculé
276
- # ------------------------------------------------------------------
277
-
278
- def run_with_ocr_text(
279
- self, image_path: str | Path, ocr_text: str,
280
- ) -> EngineResult:
281
- """Exécute le pipeline avec un texte OCR pré-fourni (corpus triplet).
282
-
283
- Utilisé quand le corpus contient des fichiers ``.ocr.txt`` : le
284
- texte OCR bruité est fourni directement, sans lancer de moteur OCR.
285
-
286
- Sprint B du plan v2.0 — délègue à
287
- ``picarones.pipelines._executor_runner.run_pipeline_via_executor``
288
- avec ``ocr_text=ocr_text``. La spec construite n'a qu'un seul
289
- step LLM et reçoit ``RAW_TEXT`` directement dans ses
290
- ``initial_inputs``.
291
-
292
- Parameters
293
- ----------
294
- image_path:
295
- Chemin de l'image (utilisée en mode multimodal, ignorée en text_only).
296
- ocr_text:
297
- Texte OCR bruité pré-calculé.
298
-
299
- Returns
300
- -------
301
- EngineResult
302
- """
303
- from picarones.adapters.legacy_pipelines._executor_runner import (
304
- run_pipeline_via_executor,
305
- )
306
-
307
- return run_pipeline_via_executor(
308
- self, Path(image_path), ocr_text=ocr_text,
309
- )
310
-
311
- # ------------------------------------------------------------------
312
- # Helpers
313
- # ------------------------------------------------------------------
314
-
315
- def _build_prompt(self, ocr_text: str = "", image_b64: str = "") -> str:
316
- """Substitue {ocr_output} et {image_b64} dans le template de prompt."""
317
- return (
318
- self._prompt_template
319
- .replace("{ocr_output}", ocr_text)
320
- .replace("{image_b64}", image_b64)
321
- )
322
-
323
- def _build_steps_info(self) -> list[dict]:
324
- steps: list[dict] = []
325
- if self.ocr_engine:
326
- steps.append({
327
- "type": "ocr",
328
- "engine": self.ocr_engine.name,
329
- "version": self.ocr_engine._safe_version(),
330
- })
331
- steps.append({
332
- "type": "llm",
333
- "model": self.llm_adapter.model,
334
- "provider": self.llm_adapter.name,
335
- "mode": self.mode.value,
336
- "prompt_file": self.prompt_path,
337
- })
338
- return steps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/app/services/_legacy_runner_adapter.py CHANGED
@@ -37,9 +37,11 @@ import logging
37
  from pathlib import Path
38
  from typing import TYPE_CHECKING, Any, Callable
39
 
40
- from picarones.adapters.legacy_engines._step_executor import (
41
- LegacyOCREngineExecutor,
42
- )
 
 
43
  from picarones.domain.artifacts import ArtifactType
44
  from picarones.domain.corpus import CorpusSpec
45
  from picarones.domain.documents import DocumentRef, GroundTruthRef
@@ -52,7 +54,6 @@ from picarones.domain.pipeline_spec import (
52
  from picarones.pipeline.llm_pipeline_builder import make_ocr_llm_pipeline_spec
53
 
54
  if TYPE_CHECKING:
55
- from picarones.adapters.legacy_engines.base import BaseOCREngine
56
  from picarones.evaluation.corpus import Corpus, Document
57
 
58
  logger = logging.getLogger(__name__)
@@ -516,26 +517,22 @@ def _is_canonical_adapter(engine: Any) -> bool:
516
  def engine_to_pipeline_spec(engine: Any) -> PipelineSpec:
517
  """Convertit un engine en ``PipelineSpec`` rewrite.
518
 
519
- Trois cas :
 
520
 
521
- - **BaseOCRAdapter** (canonique, Sprint H.2.b) : spec mono-step
522
- consommant ``engine.input_types`` et produisant
523
- ``engine.output_types``. Pas de wrapping nécessaire — l'adapter
524
- est déjà un ``StepExecutor``.
525
- - **OCRLLMPipeline** (``engine.is_pipeline = True``) : la spec
526
- composée est construite via ``make_ocr_llm_pipeline_spec``
527
  avec le mode (``text_only`` / ``text_and_image`` /
528
  ``zero_shot``), l'OCR amont (s'il existe), le LLM, et le
529
  template de prompt en ``llm_params``.
530
- - **BaseOCREngine** (legacy) : spec mono-step (IMAGE → RAW_TEXT).
531
- Le step référencera ``engine.name`` ; le caller l'enregistre
532
- dans l'adapter resolver via un ``LegacyOCREngineExecutor(engine)``.
533
 
534
  Parameters
535
  ----------
536
  engine:
537
- Instance d'un ``BaseOCRAdapter`` canonique, d'un
538
- ``BaseOCREngine`` legacy, ou d'un ``OCRLLMPipeline``.
539
 
540
  Returns
541
  -------
@@ -546,7 +543,12 @@ def engine_to_pipeline_spec(engine: Any) -> PipelineSpec:
546
  return _canonical_adapter_to_spec(engine)
547
  if getattr(engine, "is_pipeline", False):
548
  return _ocr_llm_pipeline_to_spec(engine)
549
- return _ocr_only_to_spec(engine)
 
 
 
 
 
550
 
551
 
552
  def _canonical_adapter_to_spec(adapter: Any) -> PipelineSpec:
@@ -582,25 +584,11 @@ def _canonical_adapter_to_spec(adapter: Any) -> PipelineSpec:
582
  )
583
 
584
 
585
- def _ocr_only_to_spec(engine: "BaseOCREngine") -> PipelineSpec:
586
- """Spec mono-step : un OCR legacy consommant IMAGE et produisant RAW_TEXT."""
587
- name = engine.name
588
- safe_name = _safe_pipeline_name(name)
589
- return PipelineSpec(
590
- name=f"ocr_only_{safe_name}",
591
- description=f"OCR step seul ({name}) — IMAGE → RAW_TEXT.",
592
- initial_inputs=(ArtifactType.IMAGE,),
593
- steps=(
594
- PipelineStep(
595
- id="ocr",
596
- kind="ocr",
597
- adapter_name=name,
598
- input_types=(ArtifactType.IMAGE,),
599
- output_types=(ArtifactType.RAW_TEXT,),
600
- inputs_from={ArtifactType.IMAGE: INITIAL_STEP_ID},
601
- ),
602
- ),
603
- )
604
 
605
 
606
  def _ocr_llm_pipeline_to_spec(pipeline: Any) -> PipelineSpec:
@@ -646,17 +634,15 @@ def build_adapter_resolver(
646
  """Construit un adapter resolver pour ``PipelineExecutor``.
647
 
648
  Parcourt les engines fournis et associe leur ``name`` à un
649
- ``StepExecutor`` valide :
650
-
651
- - **BaseOCRAdapter** (canonique, Sprint H.2.b) : enregistré
652
- directement (déjà ``StepExecutor``).
653
- - **OCR simple** (``BaseOCREngine`` legacy) wrapped via
654
- ``LegacyOCREngineExecutor``.
655
- - **OCRLLMPipeline** → enregistre les deux sous-composants :
656
- ``ocr_engine`` (wrapped) et ``llm_adapter`` (déjà
657
  ``StepExecutor`` natif depuis Sprint A14-S44). Le pipeline
658
- lui-même n'est pas enregistré directement — sa spec
659
- référence ses sous-steps par leur ``adapter_name``.
660
 
661
  Le resolver retourné lève ``KeyError`` si un nom inconnu est
662
  demandé.
@@ -664,8 +650,8 @@ def build_adapter_resolver(
664
  Parameters
665
  ----------
666
  engines:
667
- Liste d'instances ``BaseOCRAdapter`` (canonique) ou
668
- ``BaseOCREngine``/``OCRLLMPipeline`` (legacy) à enregistrer.
669
 
670
  Returns
671
  -------
@@ -694,22 +680,22 @@ def build_adapter_resolver(
694
  # BaseOCRAdapter : déjà StepExecutor, pas de wrapping.
695
  _register(engine.name, engine)
696
  elif getattr(engine, "is_pipeline", False):
697
- # OCRLLMPipeline (legacy) ou OCRLLMPipelineConfig
698
- # (canonique) : enregistrer ocr + llm sous-jacents.
699
  ocr_engine = getattr(engine, "ocr_engine", None)
700
  llm_adapter = getattr(engine, "llm_adapter", None)
701
  if ocr_engine is not None:
702
- if _is_canonical_adapter(ocr_engine):
703
- # BaseOCRAdapter : déjà StepExecutor.
704
- _register(ocr_engine.name, ocr_engine)
705
- else:
706
- _register(
707
- ocr_engine.name, LegacyOCREngineExecutor(ocr_engine),
708
- )
709
  if llm_adapter is not None:
710
  _register(_llm_adapter_name(llm_adapter), llm_adapter)
711
  else:
712
- _register(engine.name, LegacyOCREngineExecutor(engine))
 
 
 
 
713
 
714
  def resolver(name: str) -> Any:
715
  if name not in name_to_executor:
 
37
  from pathlib import Path
38
  from typing import TYPE_CHECKING, Any, Callable
39
 
40
+ # Sprint H.2.c.1 ``LegacyOCREngineExecutor`` n'est plus consommé :
41
+ # tous les callers passent désormais des ``BaseOCRAdapter`` canoniques
42
+ # (déjà ``StepExecutor`` natifs). L'import est retiré ; le code path
43
+ # legacy de ``build_adapter_resolver`` est désormais inaccessible et
44
+ # peut être supprimé en H.2.c.2.
45
  from picarones.domain.artifacts import ArtifactType
46
  from picarones.domain.corpus import CorpusSpec
47
  from picarones.domain.documents import DocumentRef, GroundTruthRef
 
54
  from picarones.pipeline.llm_pipeline_builder import make_ocr_llm_pipeline_spec
55
 
56
  if TYPE_CHECKING:
 
57
  from picarones.evaluation.corpus import Corpus, Document
58
 
59
  logger = logging.getLogger(__name__)
 
517
  def engine_to_pipeline_spec(engine: Any) -> PipelineSpec:
518
  """Convertit un engine en ``PipelineSpec`` rewrite.
519
 
520
+ Deux cas (Sprint H.2.c — le path legacy ``BaseOCREngine`` a
521
+ été retiré) :
522
 
523
+ - **BaseOCRAdapter** (canonique) : spec mono-step consommant
524
+ ``engine.input_types`` et produisant ``engine.output_types``.
525
+ - **OCRLLMPipelineConfig** (``engine.is_pipeline = True``) : la
526
+ spec composée est construite via ``make_ocr_llm_pipeline_spec``
 
 
527
  avec le mode (``text_only`` / ``text_and_image`` /
528
  ``zero_shot``), l'OCR amont (s'il existe), le LLM, et le
529
  template de prompt en ``llm_params``.
 
 
 
530
 
531
  Parameters
532
  ----------
533
  engine:
534
+ Instance d'un ``BaseOCRAdapter`` canonique ou d'un
535
+ ``OCRLLMPipelineConfig``.
536
 
537
  Returns
538
  -------
 
543
  return _canonical_adapter_to_spec(engine)
544
  if getattr(engine, "is_pipeline", False):
545
  return _ocr_llm_pipeline_to_spec(engine)
546
+ raise PicaronesError(
547
+ f"Type d'engine non supporté : {type(engine).__name__}. "
548
+ "Attendu : ``BaseOCRAdapter`` ou ``OCRLLMPipelineConfig``. "
549
+ "Le support legacy ``BaseOCREngine`` / ``OCRLLMPipeline`` "
550
+ "a été retiré au sprint H.2.c.",
551
+ )
552
 
553
 
554
  def _canonical_adapter_to_spec(adapter: Any) -> PipelineSpec:
 
584
  )
585
 
586
 
587
+ # Sprint H.2.c — ``_ocr_only_to_spec`` (legacy ``BaseOCREngine``
588
+ # spec mono-step en dur IMAGE RAW_TEXT) supprimé. Le path
589
+ # canonique ``_canonical_adapter_to_spec`` couvre tous les cas en
590
+ # utilisant les ``input_types``/``output_types`` déclarés par
591
+ # l'adapter.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
 
593
 
594
  def _ocr_llm_pipeline_to_spec(pipeline: Any) -> PipelineSpec:
 
634
  """Construit un adapter resolver pour ``PipelineExecutor``.
635
 
636
  Parcourt les engines fournis et associe leur ``name`` à un
637
+ ``StepExecutor`` valide (Sprint H.2.c — le path legacy
638
+ ``LegacyOCREngineExecutor`` a été retiré) :
639
+
640
+ - **BaseOCRAdapter** : enregistré directement (déjà ``StepExecutor``).
641
+ - **OCRLLMPipelineConfig** enregistre les deux sous-composants :
642
+ ``ocr_adapter`` (canonique, direct) et ``llm_adapter`` (déjà
 
 
643
  ``StepExecutor`` natif depuis Sprint A14-S44). Le pipeline
644
+ lui-même n'est pas enregistré directement — sa spec référence
645
+ ses sous-steps par leur ``adapter_name``.
646
 
647
  Le resolver retourné lève ``KeyError`` si un nom inconnu est
648
  demandé.
 
650
  Parameters
651
  ----------
652
  engines:
653
+ Liste d'instances ``BaseOCRAdapter`` ou
654
+ ``OCRLLMPipelineConfig`` à enregistrer.
655
 
656
  Returns
657
  -------
 
680
  # BaseOCRAdapter : déjà StepExecutor, pas de wrapping.
681
  _register(engine.name, engine)
682
  elif getattr(engine, "is_pipeline", False):
683
+ # OCRLLMPipelineConfig : enregistrer ocr + llm sous-jacents.
 
684
  ocr_engine = getattr(engine, "ocr_engine", None)
685
  llm_adapter = getattr(engine, "llm_adapter", None)
686
  if ocr_engine is not None:
687
+ # ``ocr_engine`` est un alias compat de ``ocr_adapter``
688
+ # (cf. ``OCRLLMPipelineConfig.ocr_engine``) toujours
689
+ # un ``BaseOCRAdapter`` canonique en H.2.c+.
690
+ _register(ocr_engine.name, ocr_engine)
 
 
 
691
  if llm_adapter is not None:
692
  _register(_llm_adapter_name(llm_adapter), llm_adapter)
693
  else:
694
+ raise PicaronesError(
695
+ f"Type d'engine non supporté pour le resolver : "
696
+ f"{type(engine).__name__}. Attendu : ``BaseOCRAdapter`` "
697
+ "ou ``OCRLLMPipelineConfig``.",
698
+ )
699
 
700
  def resolver(name: str) -> Any:
701
  if name not in name_to_executor:
scripts/gen_readme_tables.py CHANGED
@@ -72,16 +72,19 @@ _ENGINE_DESCRIPTIONS: dict[str, tuple[str, str, str]] = {
72
 
73
 
74
  def _engine_files() -> list[str]:
75
- """Retourne la liste triée des modules d'engines (sans base / factory).
76
 
77
- Lot E (2026-05) : ``picarones/engines/`` a été retiré, son canonique
78
- est ``picarones/adapters/legacy_engines/``.
 
 
79
  """
80
  out: list[str] = []
81
- engines_dir = REPO_ROOT / "picarones" / "adapters" / "legacy_engines"
 
82
  for path in sorted(engines_dir.glob("*.py")):
83
  name = path.stem
84
- if name in {"__init__", "base", "factory"}:
85
  continue
86
  out.append(name)
87
  return out
 
72
 
73
 
74
  def _engine_files() -> list[str]:
75
+ """Retourne la liste triée des modules d'OCR engines (sans helpers).
76
 
77
+ Sprint H.2.d (2026-05) : ``picarones/adapters/legacy_engines/`` a été
78
+ supprimé, le canonique est ``picarones/adapters/ocr/``. On filtre
79
+ aussi les modules helpers (``confidences``, ``precomputed``) qui ne
80
+ sont pas des engines OCR à proprement parler.
81
  """
82
  out: list[str] = []
83
+ engines_dir = REPO_ROOT / "picarones" / "adapters" / "ocr"
84
+ skip = {"__init__", "base", "factory", "confidences", "precomputed"}
85
  for path in sorted(engines_dir.glob("*.py")):
86
  name = path.stem
87
+ if name in skip:
88
  continue
89
  out.append(name)
90
  return out
tests/app/test_sprint_d2b_partial_dir_resume.py CHANGED
@@ -16,7 +16,6 @@ from __future__ import annotations
16
  import json
17
  import threading
18
  from pathlib import Path
19
- from typing import Any
20
 
21
  import pytest
22
 
 
16
  import json
17
  import threading
18
  from pathlib import Path
 
19
 
20
  import pytest
21
 
tests/architecture/test_doc_paths.py CHANGED
@@ -117,16 +117,14 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
117
  # (CHANGELOG.md, audits, sub-plans) gardent volontairement les
118
  # anciens chemins pour la traçabilité historique.
119
  # Sprint H.5 : -11 broken paths — fix des refs actives dans
120
- # docs/how-to/cli-workflows.md (cli/ → interfaces/cli/_legacy/,
121
- # extras/importers/_http.py → adapters/corpus/_http.py),
122
- # docs/explanation/narrative-engine{.,en}.md (measurements/narrative/
123
- # reports/narrative/, fixtures.py evaluation/synthetic.py),
124
- # docs/reference/normalization-profiles.md (measurements/builtin_hooks
125
- # evaluation/metrics/builtin_hooks), docs/developer/doc-consistency.md
126
- # (engines/, cli/, web/ → leurs nouveaux chemins),
127
- # docs/migration/SESSION_HANDOVER.md (refonte section 5 pour pointer
128
- # vers H.2.b-d/H.4/H.6 au lieu de l'ex sub-phase 7.B.2 obsolète).
129
- BROKEN_PATHS_BASELINE = 161
130
 
131
  #: Patrons de fichiers de documentation à scanner.
132
  DOC_GLOBS: tuple[str, ...] = (
 
117
  # (CHANGELOG.md, audits, sub-plans) gardent volontairement les
118
  # anciens chemins pour la traçabilité historique.
119
  # Sprint H.5 : -11 broken paths — fix des refs actives dans
120
+ # docs/how-to/cli-workflows.md, narrative-engine, normalization-profiles,
121
+ # doc-consistency, SESSION_HANDOVER.
122
+ # Sprint H.2.d : +1 — la suppression de ``adapters/legacy_engines/``
123
+ # et ``adapters/legacy_pipelines/`` casse 1 ref active de plus dans
124
+ # les docs migration restantes (la majorité des refs cassées
125
+ # pointaient déjà vers ces paquets dans CHANGELOG/audits historiques,
126
+ # d'où l'impact limité).
127
+ BROKEN_PATHS_BASELINE = 162
 
 
128
 
129
  #: Patrons de fichiers de documentation à scanner.
130
  DOC_GLOBS: tuple[str, ...] = (
tests/architecture/test_file_budgets.py CHANGED
@@ -33,20 +33,15 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
33
  # n'ont pas besoin de budget — leur croissance est gérée par les tests
34
  # de couverture, pas par un seuil dur).
35
  FILE_BUDGETS: dict[str, int] = {
36
- # Sprint B (plan v2.0) — helper d'exécution mono-document qui
37
- # pont OCRLLMPipeline (legacy) vers PipelineExecutor (rewrite).
38
- # Sera supprimé en Sprint C-D quand les callers consommeront des
39
- # PipelineSpec directement.
40
- "picarones/adapters/legacy_pipelines/_executor_runner.py": 470, # actuel 410
41
  # Sprint D.1 (plan v2.0) — adapter de compat run_benchmark legacy
42
  # → BenchmarkService rewrite. Module transitoire qui sera
43
  # supprimé en H.4 avec interfaces/{cli,web}/_legacy/.
44
- # Sprint D.2.b a ajouté ~260 LOC pour la branche resumable
45
- # (``_run_benchmark_with_partial``).
46
- # Sprint D.2.c-f a ajouté ~190 LOC : NER attach (post-process +
47
- # _aggregate_ner_metrics) + over_normalization dans
48
- # _build_pipeline_metadata + validate_profile.
49
- "picarones/app/services/_legacy_runner_adapter.py": 1700, # actuel 1461
50
  # --- God-modules : budget actuel + 15 % de marge.
51
  # Le rétrécissement sera l'objet d'un sprint de refactor dédié.
52
  # statistics.py (1128 lignes) a été éclaté en sous-package
 
33
  # n'ont pas besoin de budget — leur croissance est gérée par les tests
34
  # de couverture, pas par un seuil dur).
35
  FILE_BUDGETS: dict[str, int] = {
 
 
 
 
 
36
  # Sprint D.1 (plan v2.0) — adapter de compat run_benchmark legacy
37
  # → BenchmarkService rewrite. Module transitoire qui sera
38
  # supprimé en H.4 avec interfaces/{cli,web}/_legacy/.
39
+ # Sprint D.2.b a ajouté ~260 LOC pour la branche resumable.
40
+ # Sprint D.2.c-f a ajouté ~190 LOC : NER attach + over_normalization
41
+ # + validate_profile.
42
+ # Sprint H.2.c a retiré ``_ocr_only_to_spec`` legacy + simplifié
43
+ # ``build_adapter_resolver`` (canonique uniquement).
44
+ "picarones/app/services/_legacy_runner_adapter.py": 1700, # actuel ~1450
45
  # --- God-modules : budget actuel + 15 % de marge.
46
  # Le rétrécissement sera l'objet d'un sprint de refactor dédié.
47
  # statistics.py (1128 lignes) a été éclaté en sous-package
tests/docs/test_readme_consistency.py CHANGED
@@ -45,7 +45,7 @@ import pytest
45
 
46
  REPO_ROOT = Path(__file__).resolve().parents[2]
47
  README_PATH = REPO_ROOT / "README.md"
48
- ENGINES_DIR = REPO_ROOT / "picarones" / "adapters" / "legacy_engines"
49
 
50
  #: Marqueur HTML qui désactive un check sur la ligne. Format :
51
  #: ``<!-- doc-check: skip-engine -->``, ``skip-cli``, ``skip-endpoint``.
 
45
 
46
  REPO_ROOT = Path(__file__).resolve().parents[2]
47
  README_PATH = REPO_ROOT / "README.md"
48
+ ENGINES_DIR = REPO_ROOT / "picarones" / "adapters" / "ocr"
49
 
50
  #: Marqueur HTML qui désactive un check sur la ligne. Format :
51
  #: ``<!-- doc-check: skip-engine -->``, ``skip-cli``, ``skip-endpoint``.
tests/integration/test_sprint30_polish_a11y_dx.py CHANGED
@@ -16,7 +16,6 @@ Sprint 30 livre quatre durcissements transverses :
16
 
17
  from __future__ import annotations
18
 
19
- import logging
20
  from pathlib import Path
21
 
22
 
 
16
 
17
  from __future__ import annotations
18
 
 
19
  from pathlib import Path
20
 
21
 
tests/test_minimal_install.py CHANGED
@@ -265,14 +265,14 @@ def test_optional_deps_not_required_at_top_level() -> None:
265
  On vérifie ici que les modules existent et s'importent même
266
  quand on n'a pas les engines installés.
267
  """
268
- # Liste des modules engines qu'on doit pouvoir au moins charger
269
- # (pas exécuter) sans planter.
270
  optional_engine_modules = (
271
- "picarones.adapters.legacy_engines.tesseract",
272
- "picarones.adapters.legacy_engines.pero_ocr",
273
- "picarones.adapters.legacy_engines.mistral_ocr",
274
- "picarones.adapters.legacy_engines.google_vision",
275
- "picarones.adapters.legacy_engines.azure_doc_intel",
276
  )
277
  failed: list[tuple[str, str]] = []
278
  for mod_name in optional_engine_modules:
 
265
  On vérifie ici que les modules existent et s'importent même
266
  quand on n'a pas les engines installés.
267
  """
268
+ # Sprint H.2.d chemins canoniques (les modules legacy
269
+ # ``picarones.adapters.legacy_engines.*`` ont été supprimés).
270
  optional_engine_modules = (
271
+ "picarones.adapters.ocr.tesseract",
272
+ "picarones.adapters.ocr.pero_ocr",
273
+ "picarones.adapters.ocr.mistral_ocr",
274
+ "picarones.adapters.ocr.google_vision",
275
+ "picarones.adapters.ocr.azure_doc_intel",
276
  )
277
  failed: list[tuple[str, str]] = []
278
  for mod_name in optional_engine_modules: