Claude
refactor(engines): unifier l'API token_confidences Γ  un seul nom canonique
eca43d9 unverified
Raw
History Blame
14 kB
"""Interface abstraite commune Γ  tous les adaptateurs moteurs OCR.
Refactor du chantier 1 (post-Sprint 97)
---------------------------------------
Les Sprints 47-51 ont fait surcharger ``run()`` par chacun des cinq
adaptateurs OCR pour exposer ``token_confidences`` ; cinq fois la mΓͺme
structure (chronomΓ©trage + extraction native + parsing). Ce module
factorise ce pattern :
- ``_run_with_native(image_path) -> (text, native_response)`` : hook
par lequel passe dΓ©sormais ``run()``. ImplΓ©mentation par dΓ©faut qui
délègue à ``_run_ocr`` (rétrocompat avec les engines historiques et
avec les engines de test qui n'implΓ©mentent que ``_run_ocr``).
- ``_extract_raw_confidences(native) -> list[dict] | None`` : hook
optionnel Γ  surcharger pour exposer les confidences. DΓ©faut : ``None``.
- ``_normalize_token_confidences(raw)`` : helper commun (filtrage
tokens vides / nΓ©gatifs, dΓ©tection automatique d'Γ©chelle 0-100 β†’ 0-1).
ConsΓ©quence : la classe se charge seule du chronomΓ©trage, de la
gestion d'erreurs et du wrapping en ``EngineResult``. Aucun adaptateur
OCR n'a plus Γ  surcharger ``run()``.
Compat ``BaseModule`` (Sprint 33)
---------------------------------
``process()`` continue de propager le texte sous
``{ArtifactType.TEXT: ...}``. Les ``token_confidences`` ne sont pas
des artefacts β€” elles vivent dans ``EngineResult`` et restent
accessibles via la propriété ``last_run_result`` après l'exécution.
"""
from __future__ import annotations
import hashlib
import logging
import time
from abc import abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from picarones.core.modules import ArtifactType, BaseModule
logger = logging.getLogger(__name__)
@dataclass
class EngineResult:
"""RΓ©sultat brut produit par un moteur OCR sur une image."""
engine_name: str
image_path: str
text: str
duration_seconds: float
error: Optional[str] = None
metadata: dict = field(default_factory=dict)
# Sprint 42 β€” confidences au niveau token (optionnel).
# Format attendu : liste de dicts ``{"token": str, "confidence": float}``
# avec ``confidence`` ∈ [0, 1] (ou ∈ [0, 100], normalisé par le runner).
# ``None`` si le moteur ne fournit pas ce signal β€” comportement par
# dΓ©faut pour tous les adapters historiques. Quand renseignΓ©,
# le runner alimente ``DocumentResult.calibration_metrics``.
token_confidences: Optional[list[dict[str, Any]]] = None
@property
def success(self) -> bool:
return self.error is None
@property
def image_sha256(self) -> str:
return hashlib.sha256(Path(self.image_path).read_bytes()).hexdigest()
class BaseOCREngine(BaseModule):
"""Classe de base dont hΓ©ritent tous les adaptateurs OCR.
Sprint 33 β€” Phase 0.2 : ``BaseOCREngine`` hΓ©rite de ``BaseModule`` afin
que les moteurs OCR existants soient automatiquement utilisables comme
nΕ“uds d'une pipeline composΓ©e (axe B du plan d'Γ©volution).
Chantier 1 (post-Sprint 97) β€” factorisation du run() unifiΓ©
------------------------------------------------------------
Les sous-classes implΓ©mentent **un** des deux contrats suivants :
1. **Engine sans confidences** : surchargent uniquement ``_run_ocr``
qui retourne le texte. ``run()`` retourne un ``EngineResult``
avec ``token_confidences=None``.
2. **Engine avec confidences natives** : surchargent
``_run_with_native`` (un seul appel API qui retourne texte +
payload natif) et ``_extract_raw_confidences`` (parsing du
payload natif vers le format runner). ``run()`` les invoque
et propage les ``token_confidences`` dans le ``EngineResult``.
Aucune sous-classe n'a plus besoin de surcharger ``run()``.
Attribut de classe
------------------
execution_mode : ``"io"`` (dΓ©faut) ou ``"cpu"``
Indique au runner quel type d'exΓ©cuteur utiliser :
- ``"io"`` β†’ ``ThreadPoolExecutor`` (moteurs API / rΓ©seau)
- ``"cpu"`` β†’ ``ProcessPoolExecutor`` (moteurs CPU-intensifs : Tesseract, Pero, Kraken)
"""
# DΓ©claration BaseModule β€” un OCR consomme une image et produit du texte.
input_types = (ArtifactType.IMAGE,)
output_types = (ArtifactType.TEXT,)
execution_mode: str = "io"
"""``"io"`` pour ThreadPoolExecutor (dΓ©faut), ``"cpu"`` pour ProcessPoolExecutor."""
def __init__(self, config: Optional[dict] = None) -> None:
self.config: dict = config or {}
# Cache du dernier ``EngineResult`` produit par ``run()`` β€”
# exposΓ© via la propriΓ©tΓ© ``last_run_result`` pour permettre
# Γ  un orchestrateur (par exemple le pipeline_runner) de
# consulter les ``token_confidences`` après ``process()``.
self._last_run_result: Optional[EngineResult] = None
# ``name`` reste abstrait via hΓ©ritage de BaseModule (cf.
# picarones.core.modules) β€” les sous-classes le surchargent en
# ``@property`` comme dans BaseModule.
@abstractmethod
def version(self) -> str:
"""Retourne la version du moteur (ex : '5.3.0')."""
@abstractmethod
def _run_ocr(self, image_path: Path) -> str:
"""ExΓ©cute l'OCR et retourne le texte brut extrait.
Contrat **historique** conservΓ© par rΓ©trocompat. Les
adaptateurs qui veulent exposer leurs confidences natives
surchargent en plus ``_run_with_native`` et
``_extract_raw_confidences`` (cf. docstring de classe).
"""
# ──────────────────────────────────────────────────────────────────
# Hooks pour confidences natives (Chantier 1)
# ──────────────────────────────────────────────────────────────────
def _run_with_native(self, image_path: Path) -> tuple[str, Any]:
"""ExΓ©cute l'OCR et retourne ``(text, native_response)``.
Implémentation par défaut : délègue à ``_run_ocr`` et retourne
``(text, None)`` β€” comportement adaptΓ© aux engines qui
n'exposent pas de confidences (ex. tests, moteurs basiques).
Les adaptateurs avec confidences natives surchargent cette
mΓ©thode pour effectuer un seul appel API qui produit Γ  la
fois le texte et la structure (dict JSON, page layout, etc.)
Γ  partir de laquelle ``_extract_raw_confidences`` extraira
les paires (token, confidence).
"""
return self._run_ocr(image_path), None
def _extract_raw_confidences(
self, native: Any,
) -> Optional[list[dict[str, Any]]]:
"""Parse ``native`` et retourne les paires ``(token, conf)``.
Format attendu : liste de dicts ``{"token": str, "confidence":
float}`` avec ``confidence`` ∈ [0, 1] **ou** ∈ [0, 100].
``_normalize_token_confidences`` dΓ©tecte l'Γ©chelle et normalise.
Retourne ``None`` quand ``native`` est ``None`` ou que la
structure ne contient aucune confidence exploitable.
ImplΓ©mentation par dΓ©faut : ``None`` (pas de confidences).
"""
return None
@staticmethod
def _normalize_token_confidences(
raw: Optional[list[dict[str, Any]]],
) -> Optional[list[dict[str, Any]]]:
"""Filtre les confidences brutes (Γ©chelle native conservΓ©e).
- Tokens vides ou ``None`` β†’ Γ©cartΓ©s.
- Confidences nΓ©gatives (Tesseract met -1 pour les non-mots) β†’ Γ©cartΓ©es.
- Confidences non convertibles en float β†’ Γ©cartΓ©es.
L'Γ©chelle native des moteurs ([0, 100] pour Tesseract,
[0, 1] pour les autres) est conservΓ©e. La normalisation finale
au moment du calcul de calibration est faite dans
:func:`picarones.measurements.builtin_hooks.calibration_from_engine_result`.
Retourne ``None`` si aucune entrΓ©e n'est exploitable.
"""
if not raw:
return None
cleaned: list[dict[str, Any]] = []
for entry in raw:
if not isinstance(entry, dict):
continue
tok = entry.get("token")
if not isinstance(tok, str):
continue
tok = tok.strip()
if not tok:
continue
conf = entry.get("confidence")
if conf is None:
continue
try:
conf_val = float(conf)
except (TypeError, ValueError):
continue
if conf_val < 0:
continue
cleaned.append({"token": tok, "confidence": conf_val})
return cleaned or None
# ──────────────────────────────────────────────────────────────────
# ImplΓ©mentation BaseModule (Sprint 33)
# ──────────────────────────────────────────────────────────────────
def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
"""ExΓ©cute le moteur OCR comme un module gΓ©nΓ©rique.
Wrapper rΓ©trocompatible : extrait le chemin image de ``inputs``,
appelle ``run()``, et retourne la sortie sous forme de dictionnaire
``{ArtifactType.TEXT: text}``. Les erreurs sont conservΓ©es dans
le rΓ©sultat (cf. ``EngineResult.error``) plutΓ΄t que de lever.
Les ``token_confidences`` restent accessibles via
``self.last_run_result.token_confidences`` après l'appel.
"""
self.validate_inputs(inputs)
result = self.run(inputs[ArtifactType.IMAGE])
return {ArtifactType.TEXT: result.text}
def metadata(self) -> dict:
"""Expose la version du moteur dans les mΓ©tadonnΓ©es du module."""
return {"engine_version": self._safe_version()}
@property
def last_run_result(self) -> Optional[EngineResult]:
"""Dernier ``EngineResult`` produit par ``run()`` (ou ``None``).
Utile pour récupérer ``token_confidences`` après un appel à
``process()`` (qui ne les expose pas dans le bag d'artefacts du
pipeline_runner β€” les confidences ne sont pas un type
d'artefact mais une mΓ©tadonnΓ©e du calcul).
"""
return self._last_run_result
# ──────────────────────────────────────────────────────────────────
# Point d'entrΓ©e unifiΓ© : run()
# ──────────────────────────────────────────────────────────────────
def run(self, image_path: str | Path) -> EngineResult:
"""ExΓ©cute l'OCR et retourne un ``EngineResult``.
Pipeline interne :
1. ``_run_with_native(image_path)`` β†’ ``(text, native)``
(par dΓ©faut : appelle ``_run_ocr`` et retourne ``(text, None)``).
2. ``_extract_raw_confidences(native)`` β†’ liste brute ou ``None``
(par dΓ©faut : ``None``).
3. ``_normalize_token_confidences(raw)`` β†’ format runner Sprint 42
ou ``None``.
Toute exception levΓ©e par l'Γ©tape 1 est capturΓ©e et placΓ©e dans
``EngineResult.error`` ; le texte est alors ``""`` et les
confidences ``None``. Les exceptions des Γ©tapes 2-3 sont
capturΓ©es sΓ©parΓ©ment en warning : on retourne le texte avec
``token_confidences=None`` plutΓ΄t que de faire Γ©chouer toute
la mesure pour un dΓ©faut de calibration.
"""
image_path = Path(image_path)
start = time.perf_counter()
text = ""
error: Optional[str] = None
token_confidences: Optional[list[dict[str, Any]]] = None
try:
text, native = self._run_with_native(image_path)
except Exception as exc: # noqa: BLE001
text = ""
error = str(exc)
native = None
if error is None:
try:
raw = self._extract_raw_confidences(native)
token_confidences = self._normalize_token_confidences(raw)
except Exception as exc: # noqa: BLE001
logger.warning(
"[%s] extraction/normalisation des token_confidences "
"dΓ©gradΓ©e : %s",
self.name, exc,
)
token_confidences = None
duration = time.perf_counter() - start
result = EngineResult(
engine_name=self.name,
image_path=str(image_path),
text=text,
duration_seconds=round(duration, 4),
error=error,
metadata={"engine_version": self._safe_version()},
token_confidences=token_confidences,
)
self._last_run_result = result
return result
def _safe_version(self) -> str:
# Sprint 30 β€” log la stacktrace en DEBUG pour aider au diagnostic
# quand un moteur retourne ``"unknown"`` (utilisateur qui se
# demande pourquoi). Ne pollue pas l'output normal (INFO+).
try:
return self.version()
except Exception as exc: # noqa: BLE001
logging.getLogger(__name__).debug(
"[%s._safe_version] retourne 'unknown' suite Γ  %s: %s",
self.__class__.__name__, type(exc).__name__, exc,
exc_info=True,
)
return "unknown"
def __repr__(self) -> str:
return f"{self.__class__.__name__}(name={self.name!r})"