Spaces:
Sleeping
feat(sprint-H.2.c-d)!: suppression complète de adapters/legacy_engines/ et adapters/legacy_pipelines/
Browse filesSprint 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 +0 -1
- picarones/adapters/legacy_engines/__init__.py +0 -50
- picarones/adapters/legacy_engines/_step_executor.py +0 -190
- picarones/adapters/legacy_engines/azure_doc_intel.py +0 -251
- picarones/adapters/legacy_engines/base.py +0 -336
- picarones/adapters/legacy_engines/factory.py +0 -66
- picarones/adapters/legacy_engines/google_vision.py +0 -262
- picarones/adapters/legacy_engines/mistral_ocr.py +0 -237
- picarones/adapters/legacy_engines/pero_ocr.py +0 -187
- picarones/adapters/legacy_engines/tesseract.py +0 -183
- picarones/adapters/legacy_pipelines/__init__.py +0 -34
- picarones/adapters/legacy_pipelines/_executor_runner.py +0 -410
- picarones/adapters/legacy_pipelines/base.py +0 -338
- picarones/app/services/_legacy_runner_adapter.py +44 -58
- scripts/gen_readme_tables.py +8 -5
- tests/app/test_sprint_d2b_partial_dir_resume.py +0 -1
- tests/architecture/test_doc_paths.py +8 -10
- tests/architecture/test_file_budgets.py +6 -11
- tests/docs/test_readme_consistency.py +1 -1
- tests/integration/test_sprint30_polish_a11y_dx.py +0 -1
- tests/test_minimal_install.py +7 -7
|
@@ -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 |
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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})"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -37,9 +37,11 @@ import logging
|
|
| 37 |
from pathlib import Path
|
| 38 |
from typing import TYPE_CHECKING, Any, Callable
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 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 |
-
|
|
|
|
| 520 |
|
| 521 |
-
- **BaseOCRAdapter** (canonique
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
est
|
| 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
|
| 538 |
-
``
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 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 |
-
|
| 652 |
-
|
| 653 |
-
- **
|
| 654 |
-
``
|
| 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 |
-
|
| 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``
|
| 668 |
-
``
|
| 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 |
-
#
|
| 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 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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
|
| 76 |
|
| 77 |
-
|
| 78 |
-
est ``picarones/adapters/
|
|
|
|
|
|
|
| 79 |
"""
|
| 80 |
out: list[str] = []
|
| 81 |
-
engines_dir = REPO_ROOT / "picarones" / "adapters" / "
|
|
|
|
| 82 |
for path in sorted(engines_dir.glob("*.py")):
|
| 83 |
name = path.stem
|
| 84 |
-
if name in
|
| 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
|
|
@@ -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 |
|
|
@@ -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
|
| 121 |
-
#
|
| 122 |
-
#
|
| 123 |
-
#
|
| 124 |
-
# docs
|
| 125 |
-
#
|
| 126 |
-
#
|
| 127 |
-
|
| 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, ...] = (
|
|
@@ -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 |
-
#
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
-
#
|
| 49 |
-
"picarones/app/services/_legacy_runner_adapter.py": 1700, # actuel
|
| 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
|
|
@@ -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" / "
|
| 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``.
|
|
@@ -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 |
|
|
@@ -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 |
-
#
|
| 269 |
-
#
|
| 270 |
optional_engine_modules = (
|
| 271 |
-
"picarones.adapters.
|
| 272 |
-
"picarones.adapters.
|
| 273 |
-
"picarones.adapters.
|
| 274 |
-
"picarones.adapters.
|
| 275 |
-
"picarones.adapters.
|
| 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:
|