Claude commited on
Commit
cfac168
·
unverified ·
1 Parent(s): c4d7357

feat(adapters/ocr): Sprint A14-S32 — MistralOCRAdapter natif (no shim)

Browse files

Migration native du legacy picarones.engines.mistral_ocr vers le contrat
BaseOCRAdapter (S26). Pas un shim : implémente directement le contrat du
nouveau monde.

picarones/adapters/ocr/mistral_ocr.py
-------------------------------------
- MistralOCRAdapter(BaseOCRAdapter) — direct hérite, pas de shim.
- input_types = {IMAGE}, output_types = {RAW_TEXT}, execution_mode = "io"
(appels HTTP, ThreadPool dans le runner).
- Constructeur kwargs-only : name, model (défaut mistral-ocr-latest),
prompt, max_tokens, api_key (override de MISTRAL_API_KEY env var),
timeout_seconds. Validation stricte au constructeur.
- Routing automatique selon model :
· mistral-ocr-* → POST /v1/ocr via urllib (endpoint dédié, retourne
pages structurées en markdown) ;
· pixtral-* → API chat/vision via SDK mistralai (texte transcrit
par message du LLM).
- _resolve_api_key() : explicite > env var > OCRAdapterError.
- _encode_image() : détection MIME selon extension (png/jpg/tif/webp,
fallback jpeg pour les inconnues), base64 + data URI.
- execute() : valide IMAGE input + URI + fichier ; route selon model ;
écrit dans <stem>.<name>.txt à côté de l'image ; retourne Artifact
RAW_TEXT.
- Erreurs HTTP/SDK wrappées dans OCRAdapterError avec type+message.
- SDK mistralai absent → OCRAdapterError clair avec pip install.

Tests S32 dédiés (28 nouveaux)
------------------------------
- Constructor : defaults, custom name/model, rejet name vide/invalide,
rejet max_tokens et timeout_seconds non-positifs.
- Contract : isinstance BaseOCRAdapter, input/output_types,
execution_mode = "io".
- ApiKey : explicite prend priorité, env utilisé sans explicite, pas de
clé → OCRAdapterError.
- Encoding : png → image/png, jpg → image/jpeg, extension inconnue →
défaut jpeg.
- InputValidation : IMAGE absent, sans URI, image inexistante, pas de
clé API → tous OCRAdapterError.
- NativeAPI : concatène pages markdown avec \n\n, écrit à
<stem>.<name>.txt, ConnectionError → OCRAdapterError wrappé.
- VisionAPI : pixtral-* route vers chat/vision SDK, SDK manquant →
OCRAdapterError, RuntimeError SDK → OCRAdapterError wrappé.
- ArtifactID : utilise adapter name dans id "<doc>:<name>:raw_text".

Pas de confidences pour S32
---------------------------
Confidences (legacy S49 cascade pages.{words,lines,blocks}.confidence)
reportées au sprint dédié ConfidenceArtifact.

Tests : 4671 passed, 11 skipped (vs 4643 avant : +28 S32).
Lint : ruff check picarones/ tests/ → All checks passed.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

README.md CHANGED
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
396
  python -m mypy picarones/core/
397
  ```
398
 
399
- **Test suite**: ~4660 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
396
  python -m mypy picarones/core/
397
  ```
398
 
399
+ **Test suite**: ~4690 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/adapters/ocr/__init__.py CHANGED
@@ -20,6 +20,7 @@ dédiés, **natifs** au nouveau contrat (pas de shim sur le legacy
20
  from __future__ import annotations
21
 
22
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
 
23
  from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
24
  from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
25
  from picarones.adapters.ocr.tesseract import TesseractAdapter
@@ -27,6 +28,7 @@ from picarones.adapters.ocr.tesseract import TesseractAdapter
27
  __all__ = [
28
  "BaseOCRAdapter",
29
  "OCRAdapterError",
 
30
  "PeroOCRAdapter",
31
  "PrecomputedTextAdapter",
32
  "TesseractAdapter",
 
20
  from __future__ import annotations
21
 
22
  from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
23
+ from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
24
  from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
25
  from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
26
  from picarones.adapters.ocr.tesseract import TesseractAdapter
 
28
  __all__ = [
29
  "BaseOCRAdapter",
30
  "OCRAdapterError",
31
+ "MistralOCRAdapter",
32
  "PeroOCRAdapter",
33
  "PrecomputedTextAdapter",
34
  "TesseractAdapter",
picarones/adapters/ocr/mistral_ocr.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``MistralOCRAdapter`` natif — Sprint A14-S32.
2
+
3
+ Migration native du legacy ``picarones.engines.mistral_ocr.MistralOCREngine``
4
+ vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe
5
+ implémente directement le contrat du nouveau monde.
6
+
7
+ Le legacy ``MistralOCREngine`` reste en place jusqu'au S46.
8
+
9
+ Cas d'usage BnF
10
+ ---------------
11
+ Mistral AI fournit deux familles d'OCR :
12
+
13
+ - **API dédiée ``/v1/ocr``** pour les modèles ``mistral-ocr-*`` —
14
+ endpoint optimisé qui renvoie des pages structurées en markdown
15
+ (et parfois des confidences mot par mot).
16
+ - **API vision/chat** pour les modèles ``pixtral-*`` —
17
+ reconnaissance via prompt textuel + image base64.
18
+
19
+ L'adapter route automatiquement selon le nom du modèle.
20
+
21
+ Configuration
22
+ -------------
23
+ Constructeur :
24
+
25
+ - ``name`` (défaut ``"mistral_ocr"``) : identifiant de l'instance.
26
+ - ``model`` (défaut ``"mistral-ocr-latest"``) : modèle Mistral.
27
+ - ``mistral-ocr-*`` → endpoint dédié ;
28
+ - ``pixtral-*`` → API vision/chat.
29
+ - ``prompt`` : texte du prompt pour les modèles vision. Défaut :
30
+ instruction générique de transcription.
31
+ - ``max_tokens`` (défaut 4096) : limite tokens en sortie pour les
32
+ modèles vision.
33
+ - ``api_key`` : clé API Mistral. Si ``None`` (défaut), lit la
34
+ variable d'environnement ``MISTRAL_API_KEY``.
35
+ - ``timeout_seconds`` (défaut 60) : timeout HTTP pour ``urllib``.
36
+
37
+ Comportement
38
+ ------------
39
+ 1. Vérifie présence d'un ``Artifact`` ``IMAGE`` avec URI valide.
40
+ 2. Encode l'image en base64 + détecte ``image/...`` MIME selon
41
+ l'extension.
42
+ 3. Route vers ``/v1/ocr`` ou chat/vision selon ``model``.
43
+ 4. Concatène le markdown / texte de toutes les pages.
44
+ 5. Écrit dans ``<stem>.<name>.txt`` à côté de l'image.
45
+ 6. Retourne un ``Artifact`` ``RAW_TEXT``.
46
+
47
+ Anti-sur-ingénierie
48
+ -------------------
49
+ - Pas de retry / backoff (le caller wrappe si besoin).
50
+ - Pas d'extraction de confidences (legacy S49 — reportées au
51
+ sprint ``ConfidenceArtifact``).
52
+ - Pas de support multi-page (l'image est traitée comme une seule
53
+ page d'entrée — Mistral OCR retourne une liste de pages dont on
54
+ concatène les markdowns).
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import base64
60
+ import json
61
+ import os
62
+ import urllib.request
63
+ from pathlib import Path
64
+ from typing import Any
65
+
66
+ from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
67
+ from picarones.domain.artifacts import Artifact, ArtifactType
68
+
69
+
70
+ _DEFAULT_PROMPT = (
71
+ "Transcris fidèlement le texte visible sur cette image de document "
72
+ "historique. Retourne uniquement le texte, sans commentaire."
73
+ )
74
+
75
+
76
+ _MEDIA_TYPES: dict[str, str] = {
77
+ ".jpg": "image/jpeg",
78
+ ".jpeg": "image/jpeg",
79
+ ".png": "image/png",
80
+ ".tif": "image/tiff",
81
+ ".tiff": "image/tiff",
82
+ ".webp": "image/webp",
83
+ }
84
+
85
+
86
+ class MistralOCRAdapter(BaseOCRAdapter):
87
+ """Adapter Mistral OCR natif au contrat S26.
88
+
89
+ Parameters
90
+ ----------
91
+ name:
92
+ Identifiant lisible. Défaut ``"mistral_ocr"``.
93
+ model:
94
+ Modèle Mistral. ``mistral-ocr-*`` → API dédiée ``/v1/ocr``,
95
+ ``pixtral-*`` → API vision/chat. Défaut ``"mistral-ocr-latest"``.
96
+ prompt:
97
+ Prompt pour les modèles vision.
98
+ max_tokens:
99
+ Limite tokens en sortie pour les modèles vision. Défaut 4096.
100
+ api_key:
101
+ Clé API Mistral. Si ``None`` (défaut), lit
102
+ ``MISTRAL_API_KEY``.
103
+ timeout_seconds:
104
+ Timeout HTTP pour les appels ``urllib``. Défaut 60.
105
+
106
+ Raises
107
+ ------
108
+ OCRAdapterError
109
+ Si ``name`` est invalide au constructeur.
110
+ """
111
+
112
+ input_types = frozenset({ArtifactType.IMAGE})
113
+ output_types = frozenset({ArtifactType.RAW_TEXT})
114
+ execution_mode = "io"
115
+
116
+ def __init__(
117
+ self,
118
+ *,
119
+ name: str = "mistral_ocr",
120
+ model: str = "mistral-ocr-latest",
121
+ prompt: str = _DEFAULT_PROMPT,
122
+ max_tokens: int = 4096,
123
+ api_key: str | None = None,
124
+ timeout_seconds: float = 60.0,
125
+ ) -> None:
126
+ if not name or not name.strip():
127
+ raise OCRAdapterError(
128
+ "MistralOCRAdapter : name vide non autorisé.",
129
+ )
130
+ if not all(c.isalnum() or c in "_-" for c in name):
131
+ raise OCRAdapterError(
132
+ f"MistralOCRAdapter : name invalide {name!r} — "
133
+ "alphanumérique + _ - uniquement.",
134
+ )
135
+ if max_tokens <= 0:
136
+ raise OCRAdapterError(
137
+ f"MistralOCRAdapter : max_tokens doit être > 0, "
138
+ f"reçu {max_tokens}.",
139
+ )
140
+ if timeout_seconds <= 0:
141
+ raise OCRAdapterError(
142
+ f"MistralOCRAdapter : timeout_seconds doit être > 0, "
143
+ f"reçu {timeout_seconds}.",
144
+ )
145
+ self._name = name
146
+ self._model = model
147
+ self._prompt = prompt
148
+ self._max_tokens = max_tokens
149
+ self._explicit_api_key = api_key
150
+ self._timeout = timeout_seconds
151
+
152
+ @property
153
+ def name(self) -> str:
154
+ return self._name
155
+
156
+ @property
157
+ def model(self) -> str:
158
+ return self._model
159
+
160
+ def _resolve_api_key(self) -> str:
161
+ """Résout la clé API : explicite > env var.
162
+
163
+ Lève ``OCRAdapterError`` si aucune clé n'est disponible.
164
+ """
165
+ key = self._explicit_api_key or os.environ.get("MISTRAL_API_KEY")
166
+ if not key:
167
+ raise OCRAdapterError(
168
+ f"{self.name} : clé API Mistral manquante. "
169
+ "Définir MISTRAL_API_KEY ou passer api_key= au "
170
+ "constructeur.",
171
+ )
172
+ return key
173
+
174
+ def _encode_image(self, image_path: Path) -> str:
175
+ """Retourne ``data:<mime>;base64,<...>`` pour l'image."""
176
+ suffix = image_path.suffix.lower()
177
+ media_type = _MEDIA_TYPES.get(suffix, "image/jpeg")
178
+ image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
179
+ return f"data:{media_type};base64,{image_b64}"
180
+
181
+ def execute(
182
+ self,
183
+ inputs: dict[ArtifactType, Artifact],
184
+ params: dict[str, Any],
185
+ context: Any,
186
+ ) -> dict[ArtifactType, Artifact]:
187
+ """Exécute Mistral OCR sur l'image fournie.
188
+
189
+ Route vers l'API appropriée selon ``self.model`` :
190
+ - ``mistral-ocr-*`` → ``/v1/ocr`` via ``urllib`` ;
191
+ - ``pixtral-*`` → API chat/vision via SDK ``mistralai``.
192
+
193
+ Raises
194
+ ------
195
+ OCRAdapterError
196
+ Erreur d'input, clé manquante, SDK absent (pour pixtral),
197
+ ou API Mistral en erreur.
198
+ """
199
+ if ArtifactType.IMAGE not in inputs:
200
+ raise OCRAdapterError(
201
+ f"{self.name} : input IMAGE manquant.",
202
+ )
203
+ image_artifact = inputs[ArtifactType.IMAGE]
204
+ if image_artifact.uri is None:
205
+ raise OCRAdapterError(
206
+ f"{self.name} : artefact image "
207
+ f"{image_artifact.id!r} sans URI.",
208
+ )
209
+ image_path = Path(image_artifact.uri)
210
+ if not image_path.exists():
211
+ raise OCRAdapterError(
212
+ f"{self.name} : image introuvable {image_path!r}.",
213
+ )
214
+
215
+ api_key = self._resolve_api_key()
216
+ image_url = self._encode_image(image_path)
217
+
218
+ if "mistral-ocr" in self._model.lower():
219
+ text = self._call_native_ocr_api(image_url, api_key)
220
+ else:
221
+ text = self._call_chat_vision_api(image_url, api_key)
222
+
223
+ text_path = (
224
+ image_path.parent / f"{image_path.stem}.{self.name}.txt"
225
+ )
226
+ text_path.write_text(text, encoding="utf-8")
227
+
228
+ return {
229
+ ArtifactType.RAW_TEXT: Artifact(
230
+ id=f"{context.document_id}:{self.name}:raw_text",
231
+ document_id=context.document_id,
232
+ type=ArtifactType.RAW_TEXT,
233
+ produced_by_step="ocr",
234
+ uri=str(text_path),
235
+ ),
236
+ }
237
+
238
+ # ──────────────────────────────────────────────────────────────
239
+ # API natives
240
+ # ──────────────────────────────────────────────────────────────
241
+
242
+ def _call_native_ocr_api(self, image_url: str, api_key: str) -> str:
243
+ """Appelle ``POST /v1/ocr`` via urllib et retourne le markdown
244
+ concaténé."""
245
+ payload = json.dumps({
246
+ "model": self._model,
247
+ "document": {"type": "image_url", "image_url": image_url},
248
+ }).encode("utf-8")
249
+ req = urllib.request.Request(
250
+ "https://api.mistral.ai/v1/ocr",
251
+ data=payload,
252
+ headers={
253
+ "Authorization": f"Bearer {api_key}",
254
+ "Content-Type": "application/json",
255
+ },
256
+ method="POST",
257
+ )
258
+ try:
259
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
260
+ data = json.loads(resp.read().decode())
261
+ except Exception as exc:
262
+ raise OCRAdapterError(
263
+ f"{self.name} : erreur API Mistral /v1/ocr : "
264
+ f"{type(exc).__name__}: {exc}",
265
+ ) from exc
266
+ pages = data.get("pages", [])
267
+ text = "\n\n".join(p.get("markdown", "") for p in pages).strip()
268
+ return text
269
+
270
+ def _call_chat_vision_api(self, image_url: str, api_key: str) -> str:
271
+ """Appelle l'API chat/vision Mistral via le SDK ``mistralai``."""
272
+ try:
273
+ try:
274
+ from mistralai.client import Mistral
275
+ except ImportError:
276
+ from mistralai import Mistral # type: ignore[no-redef]
277
+ except ImportError as exc:
278
+ raise OCRAdapterError(
279
+ f"{self.name} : SDK 'mistralai' non installé. "
280
+ "Installer avec : pip install mistralai",
281
+ ) from exc
282
+
283
+ client = Mistral(api_key=api_key)
284
+ try:
285
+ response = client.chat.complete(
286
+ model=self._model,
287
+ messages=[
288
+ {
289
+ "role": "user",
290
+ "content": [
291
+ {"type": "text", "text": self._prompt},
292
+ {"type": "image_url", "image_url": image_url},
293
+ ],
294
+ },
295
+ ],
296
+ max_tokens=self._max_tokens,
297
+ )
298
+ except Exception as exc:
299
+ raise OCRAdapterError(
300
+ f"{self.name} : erreur API Mistral chat : "
301
+ f"{type(exc).__name__}: {exc}",
302
+ ) from exc
303
+
304
+ try:
305
+ return response.choices[0].message.content or ""
306
+ except (AttributeError, IndexError) as exc:
307
+ raise OCRAdapterError(
308
+ f"{self.name} : réponse Mistral chat malformée : {exc}",
309
+ ) from exc
310
+
311
+
312
+ __all__ = ["MistralOCRAdapter"]
tests/adapters/ocr/test_sprint_a14_s32_mistral_ocr_adapter.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S32 — ``MistralOCRAdapter`` natif au contrat S26."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ from unittest.mock import MagicMock, patch
8
+
9
+ import pytest
10
+
11
+ from picarones.adapters.ocr import (
12
+ BaseOCRAdapter,
13
+ MistralOCRAdapter,
14
+ OCRAdapterError,
15
+ )
16
+ from picarones.domain.artifacts import Artifact, ArtifactType
17
+ from picarones.pipeline.types import RunContext
18
+
19
+
20
+ def _make_image_artifact(uri: str) -> Artifact:
21
+ return Artifact(
22
+ id="d1:initial:image",
23
+ document_id="d1",
24
+ type=ArtifactType.IMAGE,
25
+ uri=uri,
26
+ )
27
+
28
+
29
+ def _make_context() -> RunContext:
30
+ return RunContext(
31
+ document_id="d1",
32
+ code_version="1.0.0",
33
+ pipeline_name="test",
34
+ )
35
+
36
+
37
+ def _make_dummy_image(tmp_path: Path) -> Path:
38
+ path = tmp_path / "page.png"
39
+ path.write_bytes(b"\x89PNG\r\n\x1a\nfakeimagebytes")
40
+ return path
41
+
42
+
43
+ # ──────────────────────────────────────────────────────────────────────
44
+ # Constructeur
45
+ # ──────────────────────────────────────────────────────────────────────
46
+
47
+
48
+ class TestMistralOCRAdapterConstructor:
49
+ def test_defaults(self) -> None:
50
+ adapter = MistralOCRAdapter()
51
+ assert adapter.name == "mistral_ocr"
52
+ assert adapter.model == "mistral-ocr-latest"
53
+
54
+ def test_custom_name(self) -> None:
55
+ adapter = MistralOCRAdapter(name="my_mistral")
56
+ assert adapter.name == "my_mistral"
57
+
58
+ def test_custom_model(self) -> None:
59
+ adapter = MistralOCRAdapter(model="pixtral-12b-2409")
60
+ assert adapter.model == "pixtral-12b-2409"
61
+
62
+ def test_rejects_empty_name(self) -> None:
63
+ with pytest.raises(OCRAdapterError, match="vide"):
64
+ MistralOCRAdapter(name="")
65
+
66
+ def test_rejects_invalid_chars_in_name(self) -> None:
67
+ with pytest.raises(OCRAdapterError, match="invalide"):
68
+ MistralOCRAdapter(name="bad name")
69
+
70
+ def test_rejects_non_positive_max_tokens(self) -> None:
71
+ with pytest.raises(OCRAdapterError, match="max_tokens"):
72
+ MistralOCRAdapter(max_tokens=0)
73
+ with pytest.raises(OCRAdapterError, match="max_tokens"):
74
+ MistralOCRAdapter(max_tokens=-1)
75
+
76
+ def test_rejects_non_positive_timeout(self) -> None:
77
+ with pytest.raises(OCRAdapterError, match="timeout_seconds"):
78
+ MistralOCRAdapter(timeout_seconds=0)
79
+ with pytest.raises(OCRAdapterError, match="timeout_seconds"):
80
+ MistralOCRAdapter(timeout_seconds=-1.0)
81
+
82
+
83
+ # ──────────────────────────────────────────────────────────────────────
84
+ # Contrat BaseOCRAdapter
85
+ # ──────────────────────────────────────────────────────────────────────
86
+
87
+
88
+ class TestMistralOCRAdapterContract:
89
+ def test_inherits_base_adapter(self) -> None:
90
+ adapter = MistralOCRAdapter()
91
+ assert isinstance(adapter, BaseOCRAdapter)
92
+
93
+ def test_input_types(self) -> None:
94
+ assert MistralOCRAdapter.input_types == frozenset({ArtifactType.IMAGE})
95
+
96
+ def test_output_types(self) -> None:
97
+ assert MistralOCRAdapter.output_types == frozenset({ArtifactType.RAW_TEXT})
98
+
99
+ def test_execution_mode_is_io(self) -> None:
100
+ """Mistral OCR fait des appels HTTP — IO-bound, ThreadPool."""
101
+ assert MistralOCRAdapter.execution_mode == "io"
102
+
103
+
104
+ # ──────────────────────────────────────────────────────────────────────
105
+ # API key resolution
106
+ # ──────────────────────────────────────────────────────────────────────
107
+
108
+
109
+ class TestMistralOCRApiKey:
110
+ def test_explicit_key_takes_priority(self) -> None:
111
+ adapter = MistralOCRAdapter(api_key="explicit_key")
112
+ # Mock l'env pour s'assurer qu'on n'utilise pas la valeur env.
113
+ with patch.dict("os.environ", {"MISTRAL_API_KEY": "env_key"}):
114
+ assert adapter._resolve_api_key() == "explicit_key"
115
+
116
+ def test_env_key_used_when_no_explicit(self) -> None:
117
+ adapter = MistralOCRAdapter()
118
+ with patch.dict("os.environ", {"MISTRAL_API_KEY": "env_key"}):
119
+ assert adapter._resolve_api_key() == "env_key"
120
+
121
+ def test_no_key_raises(self) -> None:
122
+ adapter = MistralOCRAdapter()
123
+ # Vide l'env de MISTRAL_API_KEY.
124
+ with patch.dict("os.environ", {}, clear=True):
125
+ with pytest.raises(OCRAdapterError, match="MISTRAL_API_KEY"):
126
+ adapter._resolve_api_key()
127
+
128
+
129
+ # ──────────────────────────────────────────────────────────────────────
130
+ # Encoding
131
+ # ──────────────────────────────────────────────────────────────────────
132
+
133
+
134
+ class TestMistralOCREncoding:
135
+ def test_png_extension_yields_png_mime(self, tmp_path: Path) -> None:
136
+ adapter = MistralOCRAdapter()
137
+ image_path = _make_dummy_image(tmp_path)
138
+ encoded = adapter._encode_image(image_path)
139
+ assert encoded.startswith("data:image/png;base64,")
140
+
141
+ def test_jpg_extension_yields_jpeg_mime(self, tmp_path: Path) -> None:
142
+ adapter = MistralOCRAdapter()
143
+ path = tmp_path / "img.jpg"
144
+ path.write_bytes(b"jpegbytes")
145
+ encoded = adapter._encode_image(path)
146
+ assert encoded.startswith("data:image/jpeg;base64,")
147
+
148
+ def test_unknown_extension_defaults_to_jpeg(self, tmp_path: Path) -> None:
149
+ adapter = MistralOCRAdapter()
150
+ path = tmp_path / "img.xyz"
151
+ path.write_bytes(b"random")
152
+ encoded = adapter._encode_image(path)
153
+ assert encoded.startswith("data:image/jpeg;base64,")
154
+
155
+
156
+ # ──────────────────────────────────────────────────────────────────────
157
+ # Input validation
158
+ # ──────────────────────────────────────────────────────────────────────
159
+
160
+
161
+ class TestMistralOCRInputValidation:
162
+ def test_missing_image_input_raises(self) -> None:
163
+ adapter = MistralOCRAdapter(api_key="x")
164
+ with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
165
+ adapter.execute(inputs={}, params={}, context=_make_context())
166
+
167
+ def test_image_artifact_without_uri_raises(self) -> None:
168
+ adapter = MistralOCRAdapter(api_key="x")
169
+ artifact = Artifact(
170
+ id="d1:img",
171
+ document_id="d1",
172
+ type=ArtifactType.IMAGE,
173
+ uri=None,
174
+ )
175
+ with pytest.raises(OCRAdapterError, match="sans URI"):
176
+ adapter.execute(
177
+ inputs={ArtifactType.IMAGE: artifact},
178
+ params={},
179
+ context=_make_context(),
180
+ )
181
+
182
+ def test_image_path_does_not_exist_raises(self) -> None:
183
+ adapter = MistralOCRAdapter(api_key="x")
184
+ artifact = _make_image_artifact("/nonexistent/img.png")
185
+ with pytest.raises(OCRAdapterError, match="introuvable"):
186
+ adapter.execute(
187
+ inputs={ArtifactType.IMAGE: artifact},
188
+ params={},
189
+ context=_make_context(),
190
+ )
191
+
192
+ def test_no_api_key_raises(self, tmp_path: Path) -> None:
193
+ adapter = MistralOCRAdapter() # pas d'api_key explicite
194
+ image_path = _make_dummy_image(tmp_path)
195
+ artifact = _make_image_artifact(str(image_path))
196
+ with patch.dict("os.environ", {}, clear=True):
197
+ with pytest.raises(OCRAdapterError, match="MISTRAL_API_KEY"):
198
+ adapter.execute(
199
+ inputs={ArtifactType.IMAGE: artifact},
200
+ params={},
201
+ context=_make_context(),
202
+ )
203
+
204
+
205
+ # ──────────────────────────────────────────────────────────────────────
206
+ # /v1/ocr API (mistral-ocr-* models)
207
+ # ──────────────────────────────────────────────────────────────────────
208
+
209
+
210
+ class TestMistralOCRNativeAPI:
211
+ def _mock_urlopen_ok(self, response_json: dict):
212
+ """Helper : retourne un context manager qui mock urlopen."""
213
+ mock_resp = MagicMock()
214
+ mock_resp.read.return_value = repr(response_json).encode()
215
+ # On ne peut pas json.dumps un dict avec json.dumps directement
216
+ # à cause du repr ; on encode proprement.
217
+ import json as _json
218
+ mock_resp.read.return_value = _json.dumps(response_json).encode()
219
+ mock_resp.__enter__.return_value = mock_resp
220
+ return patch("urllib.request.urlopen", return_value=mock_resp)
221
+
222
+ def test_native_api_concatenates_pages(self, tmp_path: Path) -> None:
223
+ adapter = MistralOCRAdapter(api_key="x")
224
+ image_path = _make_dummy_image(tmp_path)
225
+ artifact = _make_image_artifact(str(image_path))
226
+
227
+ response_json = {
228
+ "pages": [
229
+ {"markdown": "Page 1 contenu"},
230
+ {"markdown": "Page 2 contenu"},
231
+ ],
232
+ }
233
+
234
+ with self._mock_urlopen_ok(response_json):
235
+ result = adapter.execute(
236
+ inputs={ArtifactType.IMAGE: artifact},
237
+ params={},
238
+ context=_make_context(),
239
+ )
240
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
241
+ encoding="utf-8",
242
+ )
243
+ assert out_text == "Page 1 contenu\n\nPage 2 contenu"
244
+
245
+ def test_native_api_writes_to_stem_name_pattern(self, tmp_path: Path) -> None:
246
+ adapter = MistralOCRAdapter(api_key="x", name="my_mistral")
247
+ image_path = _make_dummy_image(tmp_path)
248
+ artifact = _make_image_artifact(str(image_path))
249
+
250
+ with self._mock_urlopen_ok({"pages": [{"markdown": "x"}]}):
251
+ result = adapter.execute(
252
+ inputs={ArtifactType.IMAGE: artifact},
253
+ params={},
254
+ context=_make_context(),
255
+ )
256
+ out_path = Path(result[ArtifactType.RAW_TEXT].uri)
257
+ assert out_path.name == "page.my_mistral.txt"
258
+
259
+ def test_native_api_raises_on_http_error(self, tmp_path: Path) -> None:
260
+ adapter = MistralOCRAdapter(api_key="x")
261
+ image_path = _make_dummy_image(tmp_path)
262
+ artifact = _make_image_artifact(str(image_path))
263
+
264
+ with patch(
265
+ "urllib.request.urlopen",
266
+ side_effect=ConnectionError("API down"),
267
+ ):
268
+ with pytest.raises(OCRAdapterError, match="ConnectionError"):
269
+ adapter.execute(
270
+ inputs={ArtifactType.IMAGE: artifact},
271
+ params={},
272
+ context=_make_context(),
273
+ )
274
+
275
+
276
+ # ──────────────────────────────────────────────────────────────────────
277
+ # Vision/chat API (pixtral-* models)
278
+ # ──────────────────────────────────────────────────────────────────────
279
+
280
+
281
+ class TestMistralOCRVisionAPI:
282
+ def test_pixtral_routes_to_vision_api(self, tmp_path: Path) -> None:
283
+ adapter = MistralOCRAdapter(
284
+ api_key="x",
285
+ model="pixtral-12b-2409",
286
+ )
287
+ image_path = _make_dummy_image(tmp_path)
288
+ artifact = _make_image_artifact(str(image_path))
289
+
290
+ # Mock le SDK mistralai.
291
+ mock_message = MagicMock()
292
+ mock_message.content = "Texte transcrit par pixtral."
293
+ mock_choice = MagicMock(message=mock_message)
294
+ mock_response = MagicMock()
295
+ mock_response.choices = [mock_choice]
296
+
297
+ mock_client = MagicMock()
298
+ mock_client.chat.complete.return_value = mock_response
299
+
300
+ fake_module = MagicMock()
301
+ fake_module.Mistral = MagicMock(return_value=mock_client)
302
+ fake_client_module = MagicMock()
303
+ fake_client_module.Mistral = fake_module.Mistral
304
+
305
+ with patch.dict(sys.modules, {
306
+ "mistralai": fake_module,
307
+ "mistralai.client": fake_client_module,
308
+ }):
309
+ result = adapter.execute(
310
+ inputs={ArtifactType.IMAGE: artifact},
311
+ params={},
312
+ context=_make_context(),
313
+ )
314
+
315
+ out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text(
316
+ encoding="utf-8",
317
+ )
318
+ assert out_text == "Texte transcrit par pixtral."
319
+
320
+ def test_pixtral_sdk_missing_raises_clean_error(
321
+ self, tmp_path: Path,
322
+ ) -> None:
323
+ adapter = MistralOCRAdapter(api_key="x", model="pixtral-12b")
324
+ image_path = _make_dummy_image(tmp_path)
325
+ artifact = _make_image_artifact(str(image_path))
326
+
327
+ with patch.dict(sys.modules, {
328
+ "mistralai": None,
329
+ "mistralai.client": None,
330
+ }):
331
+ with pytest.raises(OCRAdapterError, match="mistralai"):
332
+ adapter.execute(
333
+ inputs={ArtifactType.IMAGE: artifact},
334
+ params={},
335
+ context=_make_context(),
336
+ )
337
+
338
+ def test_pixtral_api_error_wrapped(self, tmp_path: Path) -> None:
339
+ adapter = MistralOCRAdapter(api_key="x", model="pixtral-12b")
340
+ image_path = _make_dummy_image(tmp_path)
341
+ artifact = _make_image_artifact(str(image_path))
342
+
343
+ mock_client = MagicMock()
344
+ mock_client.chat.complete.side_effect = RuntimeError("API error")
345
+
346
+ fake_module = MagicMock()
347
+ fake_module.Mistral = MagicMock(return_value=mock_client)
348
+ fake_client_module = MagicMock()
349
+ fake_client_module.Mistral = fake_module.Mistral
350
+
351
+ with patch.dict(sys.modules, {
352
+ "mistralai": fake_module,
353
+ "mistralai.client": fake_client_module,
354
+ }):
355
+ with pytest.raises(OCRAdapterError, match="RuntimeError.*API error"):
356
+ adapter.execute(
357
+ inputs={ArtifactType.IMAGE: artifact},
358
+ params={},
359
+ context=_make_context(),
360
+ )
361
+
362
+
363
+ # ──────────────────────────────────────────────────────────────────────
364
+ # Artifact ID
365
+ # ──────────────────────────────────────────────────────────────────────
366
+
367
+
368
+ class TestMistralOCRArtifactID:
369
+ def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None:
370
+ adapter = MistralOCRAdapter(api_key="x", name="custom")
371
+ image_path = _make_dummy_image(tmp_path)
372
+ artifact = _make_image_artifact(str(image_path))
373
+
374
+ mock_resp = MagicMock()
375
+ import json as _json
376
+ mock_resp.read.return_value = _json.dumps(
377
+ {"pages": [{"markdown": "x"}]},
378
+ ).encode()
379
+ mock_resp.__enter__.return_value = mock_resp
380
+
381
+ with patch("urllib.request.urlopen", return_value=mock_resp):
382
+ result = adapter.execute(
383
+ inputs={ArtifactType.IMAGE: artifact},
384
+ params={},
385
+ context=_make_context(),
386
+ )
387
+ produced = result[ArtifactType.RAW_TEXT]
388
+ assert produced.id == "d1:custom:raw_text"
389
+ assert produced.document_id == "d1"
390
+ assert produced.produced_by_step == "ocr"