Claude commited on
Commit
ca31461
·
unverified ·
1 Parent(s): 53a3d00

feat(adapters/ocr): Sprint A14-S50 — ConfidenceArtifact + Tesseract (fix audit #4)

Browse files

L'audit avait identifié que la migration native S30-S34 avait perdu
la feature token_confidences du legacy. Régression critique : les
vues de calibration (ECE/MCE, reliability diagram) étaient
inopérantes pour les pipelines new-world.

Stratégie : nouvel ArtifactType.CONFIDENCES + sidecar JSON canonique
à côté du fichier texte. Permet aux 5 OCR adapters de re-exposer
leurs confidences natives (Tesseract image_to_data, Pero
transcription_confidence, etc.) sans toucher à BaseOCRAdapter.

picarones/domain/artifacts.py
-----------------------------
- Nouveau ArtifactType.CONFIDENCES = 'confidences'.
- Schéma JSON canonique documenté : tokens[].{text, confidence ∈
[0, 1]} + extractor + model_version.

picarones/adapters/ocr/confidences.py (nouveau)
-----------------------------------------------
- filter_valid_tokens(raw) : nettoie/normalise les tokens bruts
(skip text vide, conf None ou négative ; convertit 0-100 → 0-1).
- write_confidences_sidecar() : produit
<stem>.<adapter_name>.confidences.json + Artifact CONFIDENCES.

picarones/adapters/ocr/tesseract.py — extension
-----------------------------------------------
- Nouveau param expose_confidences=True (défaut) au constructeur.
- output_types devient une property d'instance dynamique :
- True → {RAW_TEXT, CONFIDENCES}
- False → {RAW_TEXT}
Permet à PipelinePlanner de valider correctement.
- _extract_and_persist_confidences() : appelle image_to_data,
best-effort (échec → warning, OCR reste valide), normalise via
filter_valid_tokens, écrit sidecar.

Tests (13 S50 + 1 màj S30)
--------------------------
- TestFilterValidTokens : 7 cas (valides, vides, négatif, None,
format Tesseract 0-100 → 0-1, hors-range, non-numerique).
- TestWriteSidecar : path attendu, Unicode préservé, model_version
optionnel.
- TestTesseractConfidenceIntegration : sidecar produit par défaut,
pas de sidecar quand expose_confidences=False, extraction failure
graceful (RAW_TEXT toujours produit).

Tests : 37 passed (24 S30 + 13 S50, 0 régression).
Lint : All checks passed.

Pero/Mistral/Google/Azure
-------------------------
Le pattern (sidecar + filter_valid_tokens + property output_types
dynamique) sera répliqué pour les 4 autres adapters dans des sprints
de polishing dédiés (les API natives diffèrent suffisamment qu'un
seul commit S50 deviendrait gros). Tesseract est livré complet ;
les 4 autres restent au comportement S30-S34 (pas de confidences)
en attendant.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/adapters/ocr/confidences.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sidecar de confidences OCR — Sprint A14-S50.
2
+
3
+ Fix audit #4 : avant ce sprint, la migration native des 5 OCR adapters
4
+ (S30-S34) avait perdu la feature ``token_confidences`` du legacy.
5
+ Les vues de calibration (ECE/MCE, reliability diagram) devenaient
6
+ inopérantes pour les pipelines new-world.
7
+
8
+ Stratégie
9
+ ---------
10
+ Plutôt que de stuffer les confidences dans ``EngineResult`` legacy
11
+ (qui n'existe plus), on les expose comme un **artefact dédié**
12
+ ``ArtifactType.CONFIDENCES`` (sidecar JSON à côté du fichier texte).
13
+
14
+ Format JSON canonique
15
+ ---------------------
16
+
17
+ ::
18
+
19
+ {
20
+ "tokens": [
21
+ {"text": "Bonjour", "confidence": 0.95},
22
+ {"text": "le", "confidence": 0.99},
23
+ ...
24
+ ],
25
+ "extractor": "tesseract",
26
+ "model_version": "5.3.0" // optionnel
27
+ }
28
+
29
+ - ``confidence`` ∈ [0, 1] (les adapters convertissent eux-mêmes
30
+ depuis leur format natif — Tesseract retourne 0-100, on divise
31
+ par 100).
32
+ - Tokens vides ou conf négatives ignorés à la source (cf.
33
+ ``filter_valid_tokens``).
34
+
35
+ API publique
36
+ ------------
37
+ - ``filter_valid_tokens(raw)`` : nettoie une liste de dicts brutes.
38
+ - ``write_confidences_sidecar(text_path, name, tokens, ...)`` :
39
+ écrit ``<stem>.<name>.confidences.json`` à côté du fichier texte.
40
+ - ``ConfidenceToken`` (TypedDict léger) : forme attendue du dict.
41
+
42
+ Anti-sur-ingénierie
43
+ -------------------
44
+ - Pas de pydantic — TypedDict + json suffisent ; le caller normalise.
45
+ - Pas de schéma JSON publié — la stabilité sera tagguée à la livraison.
46
+ - Pas de support pour les confidences niveau ligne / paragraphe :
47
+ on aplatit tout au niveau mot (cohérent avec le legacy Sprint 47).
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import json
53
+ from pathlib import Path
54
+ from typing import Any, TypedDict
55
+
56
+ from picarones.domain.artifacts import Artifact, ArtifactType
57
+
58
+
59
+ class ConfidenceToken(TypedDict):
60
+ """Forme canonique d'un token de confidence."""
61
+
62
+ text: str
63
+ confidence: float
64
+
65
+
66
+ def filter_valid_tokens(
67
+ raw: list[dict[str, Any]],
68
+ ) -> list[ConfidenceToken]:
69
+ """Nettoie une liste brute de tokens (ignore les non-mots).
70
+
71
+ Filtre :
72
+
73
+ - ``text`` vide ou whitespace-only ;
74
+ - ``confidence`` ``None`` ou négative (Tesseract met -1 pour les
75
+ non-mots) ;
76
+ - ``confidence`` > 1.0 → divisé par 100 si ≤ 100, sinon ignoré.
77
+
78
+ Retourne une nouvelle liste, ne modifie pas l'input.
79
+ """
80
+ out: list[ConfidenceToken] = []
81
+ for entry in raw:
82
+ text = str(entry.get("text", "") or "").strip()
83
+ if not text:
84
+ continue
85
+ conf = entry.get("confidence")
86
+ if conf is None:
87
+ continue
88
+ try:
89
+ conf_f = float(conf)
90
+ except (TypeError, ValueError):
91
+ continue
92
+ if conf_f < 0:
93
+ continue
94
+ if conf_f > 1.0:
95
+ # Tesseract retourne 0-100 ; on normalise.
96
+ if conf_f <= 100.0:
97
+ conf_f = conf_f / 100.0
98
+ else:
99
+ # > 100 = donnée corrompue, on ignore.
100
+ continue
101
+ out.append({"text": text, "confidence": conf_f})
102
+ return out
103
+
104
+
105
+ def write_confidences_sidecar(
106
+ text_path: Path,
107
+ adapter_name: str,
108
+ tokens: list[ConfidenceToken],
109
+ *,
110
+ document_id: str,
111
+ extractor: str | None = None,
112
+ model_version: str | None = None,
113
+ ) -> Artifact:
114
+ """Écrit un sidecar JSON ``<stem>.<adapter_name>.confidences.json``
115
+ à côté du fichier texte produit par l'OCR.
116
+
117
+ Returns
118
+ -------
119
+ Artifact
120
+ Artifact ``CONFIDENCES`` avec ``uri`` pointant vers le sidecar.
121
+ """
122
+ sidecar_path = (
123
+ text_path.parent
124
+ / f"{text_path.stem}.{adapter_name}.confidences.json"
125
+ )
126
+ payload = {
127
+ "tokens": tokens,
128
+ "extractor": extractor or adapter_name,
129
+ "model_version": model_version,
130
+ }
131
+ sidecar_path.write_text(
132
+ json.dumps(payload, ensure_ascii=False, indent=2),
133
+ encoding="utf-8",
134
+ )
135
+ return Artifact(
136
+ id=f"{document_id}:{adapter_name}:confidences",
137
+ document_id=document_id,
138
+ type=ArtifactType.CONFIDENCES,
139
+ produced_by_step="ocr",
140
+ uri=str(sidecar_path),
141
+ )
142
+
143
+
144
+ __all__ = [
145
+ "ConfidenceToken",
146
+ "filter_valid_tokens",
147
+ "write_confidences_sidecar",
148
+ ]
picarones/adapters/ocr/tesseract.py CHANGED
@@ -98,7 +98,12 @@ class TesseractAdapter(BaseOCRAdapter):
98
  """
99
 
100
  input_types = frozenset({ArtifactType.IMAGE})
101
- output_types = frozenset({ArtifactType.RAW_TEXT})
 
 
 
 
 
102
  execution_mode = "cpu"
103
 
104
  def __init__(
@@ -109,6 +114,7 @@ class TesseractAdapter(BaseOCRAdapter):
109
  psm: int = 6,
110
  oem: int = 3,
111
  tesseract_cmd: str | None = None,
 
112
  ) -> None:
113
  if not name or not name.strip():
114
  raise OCRAdapterError(
@@ -132,11 +138,31 @@ class TesseractAdapter(BaseOCRAdapter):
132
  self._psm = psm
133
  self._oem = oem
134
  self._tesseract_cmd = tesseract_cmd
 
135
 
136
  @property
137
  def name(self) -> str:
138
  return self._name
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  @property
141
  def lang(self) -> str:
142
  return self._lang
@@ -223,7 +249,7 @@ class TesseractAdapter(BaseOCRAdapter):
223
  )
224
  text_path.write_text(text, encoding="utf-8")
225
 
226
- return {
227
  ArtifactType.RAW_TEXT: Artifact(
228
  id=f"{context.document_id}:{self.name}:raw_text",
229
  document_id=context.document_id,
@@ -233,5 +259,80 @@ class TesseractAdapter(BaseOCRAdapter):
233
  ),
234
  }
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  __all__ = ["TesseractAdapter"]
 
98
  """
99
 
100
  input_types = frozenset({ArtifactType.IMAGE})
101
+ # Sprint S50 : ``output_types`` est désormais une property
102
+ # d'instance qui inclut CONFIDENCES si et seulement si
103
+ # ``expose_confidences=True`` (défaut). Permet de désactiver
104
+ # la production du sidecar en mode opt-out sans déclarer un
105
+ # output que l'adapter ne produit pas (l'executor validerait
106
+ # alors un manque).
107
  execution_mode = "cpu"
108
 
109
  def __init__(
 
114
  psm: int = 6,
115
  oem: int = 3,
116
  tesseract_cmd: str | None = None,
117
+ expose_confidences: bool = True,
118
  ) -> None:
119
  if not name or not name.strip():
120
  raise OCRAdapterError(
 
138
  self._psm = psm
139
  self._oem = oem
140
  self._tesseract_cmd = tesseract_cmd
141
+ self._expose_confidences = expose_confidences
142
 
143
  @property
144
  def name(self) -> str:
145
  return self._name
146
 
147
+ @property
148
+ def output_types(self) -> frozenset: # type: ignore[override]
149
+ """Output_types dynamique selon ``expose_confidences``.
150
+
151
+ Sprint S50 : si l'instance expose les confidences, déclare
152
+ ``{RAW_TEXT, CONFIDENCES}`` ; sinon ``{RAW_TEXT}`` seul.
153
+ Le ``PipelinePlanner`` lit cette propriété pour valider
154
+ que les types s'enchaînent.
155
+ """
156
+ if self._expose_confidences:
157
+ return frozenset(
158
+ {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES},
159
+ )
160
+ return frozenset({ArtifactType.RAW_TEXT})
161
+
162
+ @property
163
+ def expose_confidences(self) -> bool:
164
+ return self._expose_confidences
165
+
166
  @property
167
  def lang(self) -> str:
168
  return self._lang
 
249
  )
250
  text_path.write_text(text, encoding="utf-8")
251
 
252
+ outputs: dict = {
253
  ArtifactType.RAW_TEXT: Artifact(
254
  id=f"{context.document_id}:{self.name}:raw_text",
255
  document_id=context.document_id,
 
259
  ),
260
  }
261
 
262
+ # Sprint S50 : extraction des confidences via image_to_data
263
+ # (best-effort). Si l'extraction échoue, on log et on saute
264
+ # — l'OCR reste valide, seule la calibration est indisponible
265
+ # pour ce document.
266
+ if self._expose_confidences:
267
+ confidences_artifact = self._extract_and_persist_confidences(
268
+ image_path=image_path,
269
+ text_path=text_path,
270
+ pytesseract_module=pytesseract,
271
+ pil_image_class=Image,
272
+ custom_config=custom_config,
273
+ document_id=context.document_id,
274
+ )
275
+ if confidences_artifact is not None:
276
+ outputs[ArtifactType.CONFIDENCES] = confidences_artifact
277
+
278
+ return outputs
279
+
280
+ def _extract_and_persist_confidences(
281
+ self,
282
+ *,
283
+ image_path: Path,
284
+ text_path: Path,
285
+ pytesseract_module,
286
+ pil_image_class,
287
+ custom_config: str,
288
+ document_id: str,
289
+ ) -> Artifact | None:
290
+ """Appelle ``image_to_data`` puis écrit le sidecar JSON.
291
+
292
+ Retourne l'``Artifact CONFIDENCES`` ou ``None`` si l'extraction
293
+ a échoué (warning loggé, OCR reste valide).
294
+ """
295
+ import logging
296
+ logger = logging.getLogger(__name__)
297
+
298
+ from picarones.adapters.ocr.confidences import (
299
+ filter_valid_tokens,
300
+ write_confidences_sidecar,
301
+ )
302
+
303
+ try:
304
+ with pil_image_class.open(image_path) as image:
305
+ data = pytesseract_module.image_to_data(
306
+ image,
307
+ lang=self._lang,
308
+ config=custom_config,
309
+ output_type=pytesseract_module.Output.DICT,
310
+ )
311
+ except Exception as exc: # noqa: BLE001 — best-effort
312
+ logger.warning(
313
+ "[%s] image_to_data indisponible (%s) — calibration "
314
+ "sautée pour ce document.", self._name, exc,
315
+ )
316
+ return None
317
+
318
+ # Format Tesseract : dict {"text": [...], "conf": [...]}.
319
+ texts = data.get("text") or []
320
+ confs = data.get("conf") or []
321
+ raw = [
322
+ {"text": t, "confidence": c}
323
+ for t, c in zip(texts, confs)
324
+ ]
325
+ tokens = filter_valid_tokens(raw)
326
+ return write_confidences_sidecar(
327
+ text_path=text_path,
328
+ adapter_name=self._name,
329
+ tokens=tokens,
330
+ document_id=document_id,
331
+ extractor="tesseract",
332
+ )
333
+
334
+
335
+ __all__ = ["TesseractAdapter"]
336
+
337
 
338
  __all__ = ["TesseractAdapter"]
picarones/domain/artifacts.py CHANGED
@@ -94,6 +94,18 @@ class ArtifactType(str, Enum):
94
  #: ``error_absorption``.
95
  ALIGNMENT = "alignment"
96
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  def compute_content_hash(payload: bytes) -> str:
99
  """SHA-256 hex (64 chars) d'un payload binaire.
 
94
  #: ``error_absorption``.
95
  ALIGNMENT = "alignment"
96
 
97
+ #: Confidences OCR au niveau token (Sprint S50). Sidecar JSON
98
+ #: produit par les adapters OCR qui exposent des scores natifs
99
+ #: (Tesseract image_to_data, Pero transcription_confidence,
100
+ #: Mistral OCR API confidences, Google Vision Word.confidence,
101
+ #: Azure DI Word.confidence).
102
+ #:
103
+ #: Schéma JSON : ``{"tokens": [{"text": str, "confidence":
104
+ #: float ∈ [0, 1]}], "extractor": str, "model_version": str |
105
+ #: null}``. Consommé par les vues de calibration (ECE/MCE,
106
+ #: reliability diagram).
107
+ CONFIDENCES = "confidences"
108
+
109
 
110
  def compute_content_hash(payload: bytes) -> str:
111
  """SHA-256 hex (64 chars) d'un payload binaire.
tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py CHANGED
@@ -128,7 +128,14 @@ class TestTesseractAdapterContract:
128
  assert TesseractAdapter.input_types == frozenset({ArtifactType.IMAGE})
129
 
130
  def test_output_types(self) -> None:
131
- assert TesseractAdapter.output_types == frozenset({ArtifactType.RAW_TEXT})
 
 
 
 
 
 
 
132
 
133
  def test_execution_mode_is_cpu(self) -> None:
134
  """Tesseract est CPU-bound — utilise un ProcessPool dans le runner."""
 
128
  assert TesseractAdapter.input_types == frozenset({ArtifactType.IMAGE})
129
 
130
  def test_output_types(self) -> None:
131
+ # Sprint S50 : output_types est une property d'instance qui
132
+ # dépend de ``expose_confidences``.
133
+ assert TesseractAdapter().output_types == frozenset(
134
+ {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES},
135
+ )
136
+ assert TesseractAdapter(
137
+ expose_confidences=False,
138
+ ).output_types == frozenset({ArtifactType.RAW_TEXT})
139
 
140
  def test_execution_mode_is_cpu(self) -> None:
141
  """Tesseract est CPU-bound — utilise un ProcessPool dans le runner."""
tests/adapters/ocr/test_sprint_a14_s50_confidences.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S50 — sidecar de confidences OCR (fix audit #4).
2
+
3
+ Couvre :
4
+ 1. ``filter_valid_tokens`` — normalisation et filtrage des tokens.
5
+ 2. ``write_confidences_sidecar`` — fichier JSON canonique.
6
+ 3. Intégration ``TesseractAdapter`` — sidecar produit en parallèle
7
+ du fichier texte ; opt-out via ``expose_confidences=False``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from unittest.mock import MagicMock, patch
15
+
16
+ from picarones.adapters.ocr import TesseractAdapter
17
+ from picarones.adapters.ocr.confidences import (
18
+ filter_valid_tokens,
19
+ write_confidences_sidecar,
20
+ )
21
+ from picarones.domain.artifacts import Artifact, ArtifactType
22
+ from picarones.pipeline.types import RunContext
23
+
24
+
25
+ # ──────────────────────────────────────────────────────────────────────
26
+ # filter_valid_tokens
27
+ # ──────────────────────────────────────────────────────────────────────
28
+
29
+
30
+ class TestFilterValidTokens:
31
+ def test_valid_tokens_passed_through(self) -> None:
32
+ result = filter_valid_tokens([
33
+ {"text": "Hello", "confidence": 0.95},
34
+ {"text": "world", "confidence": 0.80},
35
+ ])
36
+ assert len(result) == 2
37
+ assert result[0]["text"] == "Hello"
38
+ assert result[0]["confidence"] == 0.95
39
+
40
+ def test_empty_text_filtered(self) -> None:
41
+ result = filter_valid_tokens([
42
+ {"text": "", "confidence": 0.9},
43
+ {"text": " ", "confidence": 0.8},
44
+ {"text": "ok", "confidence": 0.7},
45
+ ])
46
+ assert len(result) == 1
47
+ assert result[0]["text"] == "ok"
48
+
49
+ def test_negative_confidence_filtered(self) -> None:
50
+ result = filter_valid_tokens([
51
+ {"text": "ok", "confidence": -1},
52
+ {"text": "good", "confidence": 0.5},
53
+ ])
54
+ assert len(result) == 1
55
+ assert result[0]["text"] == "good"
56
+
57
+ def test_none_confidence_filtered(self) -> None:
58
+ result = filter_valid_tokens([
59
+ {"text": "x", "confidence": None},
60
+ {"text": "y", "confidence": 0.6},
61
+ ])
62
+ assert len(result) == 1
63
+ assert result[0]["text"] == "y"
64
+
65
+ def test_tesseract_format_normalized(self) -> None:
66
+ """Tesseract retourne 0-100 ; on normalise à [0, 1]."""
67
+ result = filter_valid_tokens([
68
+ {"text": "Hello", "confidence": 95},
69
+ {"text": "world", "confidence": 80.5},
70
+ ])
71
+ assert result[0]["confidence"] == 0.95
72
+ assert result[1]["confidence"] == 0.805
73
+
74
+ def test_out_of_range_filtered(self) -> None:
75
+ result = filter_valid_tokens([
76
+ {"text": "x", "confidence": 9999}, # > 100, ignoré
77
+ {"text": "y", "confidence": 50}, # OK normalisé à 0.5
78
+ ])
79
+ assert len(result) == 1
80
+ assert result[0]["text"] == "y"
81
+ assert result[0]["confidence"] == 0.5
82
+
83
+ def test_non_numeric_filtered(self) -> None:
84
+ result = filter_valid_tokens([
85
+ {"text": "x", "confidence": "not a number"},
86
+ {"text": "y", "confidence": 0.5},
87
+ ])
88
+ assert len(result) == 1
89
+
90
+
91
+ # ──────────────────────────────────────────────────────────────────────
92
+ # write_confidences_sidecar
93
+ # ──────────────────────────────────────────────────────────────────────
94
+
95
+
96
+ class TestWriteSidecar:
97
+ def test_writes_json_at_expected_path(self, tmp_path: Path) -> None:
98
+ text_path = tmp_path / "doc.txt"
99
+ text_path.write_text("Hello world", encoding="utf-8")
100
+ artifact = write_confidences_sidecar(
101
+ text_path=text_path,
102
+ adapter_name="tesseract",
103
+ tokens=[{"text": "Hello", "confidence": 0.9}],
104
+ document_id="doc01",
105
+ extractor="tesseract",
106
+ )
107
+ sidecar = tmp_path / "doc.tesseract.confidences.json"
108
+ assert sidecar.exists()
109
+ payload = json.loads(sidecar.read_text(encoding="utf-8"))
110
+ assert payload["tokens"] == [
111
+ {"text": "Hello", "confidence": 0.9},
112
+ ]
113
+ assert payload["extractor"] == "tesseract"
114
+ assert payload["model_version"] is None
115
+ # Artifact CONFIDENCES.
116
+ assert artifact.type == ArtifactType.CONFIDENCES
117
+ assert artifact.uri == str(sidecar)
118
+ assert artifact.id == "doc01:tesseract:confidences"
119
+
120
+ def test_unicode_preserved(self, tmp_path: Path) -> None:
121
+ text_path = tmp_path / "doc.txt"
122
+ text_path.write_text("ok", encoding="utf-8")
123
+ write_confidences_sidecar(
124
+ text_path=text_path,
125
+ adapter_name="tesseract",
126
+ tokens=[{"text": "français", "confidence": 0.9}],
127
+ document_id="doc01",
128
+ )
129
+ sidecar = tmp_path / "doc.tesseract.confidences.json"
130
+ # ensure_ascii=False → caractères Unicode bruts.
131
+ assert "français" in sidecar.read_text(encoding="utf-8")
132
+
133
+ def test_model_version_when_provided(self, tmp_path: Path) -> None:
134
+ text_path = tmp_path / "doc.txt"
135
+ text_path.write_text("ok", encoding="utf-8")
136
+ write_confidences_sidecar(
137
+ text_path=text_path,
138
+ adapter_name="tesseract",
139
+ tokens=[],
140
+ document_id="doc01",
141
+ model_version="5.3.0",
142
+ )
143
+ sidecar = tmp_path / "doc.tesseract.confidences.json"
144
+ payload = json.loads(sidecar.read_text(encoding="utf-8"))
145
+ assert payload["model_version"] == "5.3.0"
146
+
147
+
148
+ # ──────────────────────────────────────────────────────────────────────
149
+ # Intégration TesseractAdapter
150
+ # ──────────────────────────────────────────────────────────────────────
151
+
152
+
153
+ def _make_image_artifact(uri: str) -> Artifact:
154
+ return Artifact(
155
+ id="d1:img",
156
+ document_id="d1",
157
+ type=ArtifactType.IMAGE,
158
+ uri=uri,
159
+ )
160
+
161
+
162
+ def _make_context() -> RunContext:
163
+ return RunContext(
164
+ document_id="d1",
165
+ code_version="1.0.0",
166
+ pipeline_name="test",
167
+ )
168
+
169
+
170
+ class TestTesseractConfidenceIntegration:
171
+ def _create_dummy_image(self, tmp_path: Path) -> Path:
172
+ path = tmp_path / "page.png"
173
+ path.write_bytes(b"\x89PNG\r\n\x1a\n")
174
+ return path
175
+
176
+ @patch("PIL.Image.open")
177
+ @patch("pytesseract.image_to_string")
178
+ @patch("pytesseract.image_to_data")
179
+ def test_sidecar_produced_by_default(
180
+ self,
181
+ mock_image_to_data: MagicMock,
182
+ mock_image_to_string: MagicMock,
183
+ mock_image_open: MagicMock,
184
+ tmp_path: Path,
185
+ ) -> None:
186
+ mock_image_to_string.return_value = "Hello world"
187
+ mock_image_to_data.return_value = {
188
+ "text": ["Hello", "world"],
189
+ "conf": [95, 88],
190
+ }
191
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
192
+
193
+ adapter = TesseractAdapter() # expose_confidences=True par défaut
194
+ image_path = self._create_dummy_image(tmp_path)
195
+ result = adapter.execute(
196
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
197
+ params={},
198
+ context=_make_context(),
199
+ )
200
+ # Outputs : RAW_TEXT + CONFIDENCES.
201
+ assert ArtifactType.RAW_TEXT in result
202
+ assert ArtifactType.CONFIDENCES in result
203
+ sidecar_path = Path(result[ArtifactType.CONFIDENCES].uri)
204
+ assert sidecar_path.exists()
205
+ payload = json.loads(sidecar_path.read_text(encoding="utf-8"))
206
+ assert payload["tokens"] == [
207
+ {"text": "Hello", "confidence": 0.95},
208
+ {"text": "world", "confidence": 0.88},
209
+ ]
210
+ assert payload["extractor"] == "tesseract"
211
+
212
+ @patch("PIL.Image.open")
213
+ @patch("pytesseract.image_to_string")
214
+ def test_no_sidecar_when_expose_confidences_false(
215
+ self,
216
+ mock_image_to_string: MagicMock,
217
+ mock_image_open: MagicMock,
218
+ tmp_path: Path,
219
+ ) -> None:
220
+ mock_image_to_string.return_value = "Hello world"
221
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
222
+ adapter = TesseractAdapter(expose_confidences=False)
223
+ image_path = self._create_dummy_image(tmp_path)
224
+ result = adapter.execute(
225
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
226
+ params={},
227
+ context=_make_context(),
228
+ )
229
+ # Pas de CONFIDENCES dans les outputs.
230
+ assert ArtifactType.RAW_TEXT in result
231
+ assert ArtifactType.CONFIDENCES not in result
232
+ # Pas de sidecar sur disque.
233
+ sidecars = list(tmp_path.glob("*.confidences.json"))
234
+ assert sidecars == []
235
+
236
+ @patch("PIL.Image.open")
237
+ @patch("pytesseract.image_to_string")
238
+ @patch("pytesseract.image_to_data")
239
+ def test_extraction_failure_is_graceful(
240
+ self,
241
+ mock_image_to_data: MagicMock,
242
+ mock_image_to_string: MagicMock,
243
+ mock_image_open: MagicMock,
244
+ tmp_path: Path,
245
+ ) -> None:
246
+ """Si image_to_data plante, l'OCR doit malgré tout produire
247
+ RAW_TEXT — seule la calibration est sautée pour ce document."""
248
+ mock_image_to_string.return_value = "Hello world"
249
+ mock_image_to_data.side_effect = RuntimeError(
250
+ "image_to_data crashed",
251
+ )
252
+ mock_image_open.return_value.__enter__.return_value = MagicMock()
253
+ adapter = TesseractAdapter()
254
+ image_path = self._create_dummy_image(tmp_path)
255
+ result = adapter.execute(
256
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
257
+ params={},
258
+ context=_make_context(),
259
+ )
260
+ assert ArtifactType.RAW_TEXT in result
261
+ # CONFIDENCES absent — extraction a échoué silencieusement.
262
+ assert ArtifactType.CONFIDENCES not in result