Claude commited on
Commit
dd0db4e
·
unverified ·
1 Parent(s): f003981

feat(adapters/llm): Sprint A14-S44 — BaseLLMAdapter implémente StepExecutor

Browse files

Les 4 LLM adapters (Anthropic, Mistral, OpenAI, Ollama) sont désormais
**directement** utilisables comme steps de pipeline sans wrapper / shim.

picarones/adapters/llm/base.py
------------------------------
BaseLLMAdapter implémente nativement le contrat StepExecutor du
pipeline (S6) en plus de son API LLM historique (complete()) :

- ``input_types`` (property, défaut {RAW_TEXT}, surchargeable).
- ``output_types`` (property, défaut {CORRECTED_TEXT}, surchargeable).
- ``execution_mode = "io"`` (LLM via API → IO-bound, ThreadPool).
- ``DEFAULT_CORRECTION_PROMPT`` (configurable via
config["correction_prompt"]).
- ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]`` :
· valide RAW_TEXT input (URI + fichier existe → OCRAdapterError sinon) ;
· charge le texte UTF-8 ;
· optionnellement encode IMAGE en base64 (mode VLM si supporté) ;
· format prompt avec {text} ;
· appelle self.complete(prompt, image_b64) avec retry hérité ;
· si LLMResult.error → OCRAdapterError ;
· écrit dans <stem>.<name>.corrected.txt ;
· retourne Artifact CORRECTED_TEXT avec id "<doc>:<name>:corrected_text".

Pas de wrapper externe : le contrat StepExecutor vit dans la base, partagé
nativement par les 4 adapters concrets via héritage.

Régressions corrigées
---------------------
- tests/app/test_run_orchestrator.py : assertion "3 fichiers" → "4
fichiers" (artifacts_index ajouté en S41).
- tests/architecture/test_file_budgets.py : ajout de
benchmark_service.py (400 lignes, S41) et adapters/llm/base.py
(410 lignes, S44) au tableau des budgets surveillés.

Tests S44 dédiés (18 nouveaux)
------------------------------
- BaseLLMAdapterContract : input_types, output_types,
execution_mode = "io".
- LLMExecuteNominal : correction basique → fichier
<stem>.<name>.corrected.txt avec contenu LLM, artifact id correct,
prompt formaté avec {text}, custom prompt via config.
- LLMExecuteErrors : RAW_TEXT manquant, sans URI, fichier inexistant,
LLM call failing → tous OCRAdapterError.
- LLMExecuteWithImage : IMAGE optionnel encodé en base64, omis si
absent.
- ConcreteAdaptersInheritContract : OpenAI/Anthropic/Mistral/Ollama
ont tous execute() + input_types + output_types.
- PipelineIntegration : un LLM adapter se branche directement comme
step de pipeline via PipelineExecutor.run() (test bout-en-bout).

Tests : 4881 passed, 11 skipped (vs 4863 avant : +18 S44).
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**: ~4840 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**: ~4880 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/llm/base.py CHANGED
@@ -182,6 +182,20 @@ class BaseLLMAdapter(ABC):
182
  un log discriminant par ``status_code`` (401 → clé invalide,
183
  429 → rate limit, 5xx → serveur). Auparavant ce log était
184
  dupliqué chez Mistral/OpenAI et absent chez Anthropic.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  """
186
 
187
  # Variable d'environnement portant la clé API. Sous-classes
@@ -190,6 +204,37 @@ class BaseLLMAdapter(ABC):
190
  # pour les providers sans clé (Ollama).
191
  api_key_env_var: Optional[str] = None
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  def __init__(
194
  self,
195
  model: Optional[str] = None,
@@ -267,6 +312,92 @@ class BaseLLMAdapter(ABC):
267
  error=str(last_exc),
268
  )
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  def __repr__(self) -> str:
271
  return f"{self.__class__.__name__}(model={self.model!r})"
272
 
 
182
  un log discriminant par ``status_code`` (401 → clé invalide,
183
  429 → rate limit, 5xx → serveur). Auparavant ce log était
184
  dupliqué chez Mistral/OpenAI et absent chez Anthropic.
185
+
186
+ Sprint A14-S44 — intégration pipeline native
187
+ ---------------------------------------------
188
+ ``BaseLLMAdapter`` implémente désormais le contrat ``StepExecutor``
189
+ du pipeline (``input_types``, ``output_types``, ``execution_mode``,
190
+ ``execute(inputs, params, context)``) — un adapter LLM est
191
+ directement utilisable comme step de pipeline pour la post-correction
192
+ de texte OCR. Pas de wrapper / shim : la méthode ``execute`` vit
193
+ dans la base et est partagée par les 4 adapters concrets.
194
+
195
+ Convention par défaut : un LLM consomme ``RAW_TEXT`` (depuis l'OCR
196
+ en amont) et produit ``CORRECTED_TEXT``. Une sous-classe peut
197
+ surcharger ``input_types`` / ``output_types`` si elle implémente un
198
+ autre contrat (ex : ALTO → ALTO pour un module de remappage).
199
  """
200
 
201
  # Variable d'environnement portant la clé API. Sous-classes
 
204
  # pour les providers sans clé (Ollama).
205
  api_key_env_var: Optional[str] = None
206
 
207
+ # ──────────────────────────────────────────────────────────────────
208
+ # Sprint A14-S44 — contrat StepExecutor du pipeline
209
+ # ──────────────────────────────────────────────────────────────────
210
+
211
+ #: Types d'artefacts consommés par défaut. Surchargeable par
212
+ #: une sous-classe qui consommerait des artefacts différents
213
+ #: (ex : ALTO_XML pour un remappeur ALTO LLM).
214
+ @property
215
+ def input_types(self) -> "frozenset":
216
+ from picarones.domain.artifacts import ArtifactType
217
+ return frozenset({ArtifactType.RAW_TEXT})
218
+
219
+ @property
220
+ def output_types(self) -> "frozenset":
221
+ from picarones.domain.artifacts import ArtifactType
222
+ return frozenset({ArtifactType.CORRECTED_TEXT})
223
+
224
+ #: Mode d'exécution : LLM via API → IO-bound → ThreadPool dans le
225
+ #: runner. Une sous-classe locale (Ollama CPU-bound) peut
226
+ #: surcharger en ``"cpu"``.
227
+ execution_mode: str = "io"
228
+
229
+ #: Prompt de post-correction par défaut. Surchargeable via
230
+ #: ``config["correction_prompt"]`` au constructeur.
231
+ DEFAULT_CORRECTION_PROMPT: str = (
232
+ "Corrige les erreurs OCR dans le texte suivant en conservant "
233
+ "fidèlement la langue, l'orthographe historique et la "
234
+ "ponctuation. Retourne uniquement le texte corrigé, sans "
235
+ "commentaire :\n\n{text}"
236
+ )
237
+
238
  def __init__(
239
  self,
240
  model: Optional[str] = None,
 
312
  error=str(last_exc),
313
  )
314
 
315
+ # ──────────────────────────────────────────────────────────────────
316
+ # Sprint A14-S44 — execute() pour le pipeline
317
+ # ──────────────────────────────────────────────────────────────────
318
+
319
+ def execute(
320
+ self,
321
+ inputs: dict,
322
+ params: dict,
323
+ context: Any,
324
+ ) -> dict:
325
+ """Exécute la post-correction LLM en tant que step de pipeline.
326
+
327
+ Convention par défaut : lit ``inputs[RAW_TEXT]`` (Artifact),
328
+ charge son contenu UTF-8 depuis l'URI, appelle ``self.complete``
329
+ avec le ``correction_prompt`` formaté, écrit le résultat dans
330
+ un fichier ``<input_stem>.<adapter_name>.corrected.txt``, et
331
+ retourne ``{CORRECTED_TEXT: Artifact}``.
332
+
333
+ Le caller (``PipelineExecutor``) catch les exceptions ; on les
334
+ propage telles quelles.
335
+
336
+ Optionnel : si ``inputs[IMAGE]`` est présent, l'image est
337
+ encodée en base64 et passée au LLM (mode VLM). Les sous-classes
338
+ qui ne supportent pas la vision (ex. ollama texte) ignorent
339
+ silencieusement.
340
+ """
341
+ from pathlib import Path
342
+ import base64
343
+
344
+ from picarones.adapters.ocr.base import OCRAdapterError
345
+ from picarones.domain.artifacts import Artifact, ArtifactType
346
+
347
+ if ArtifactType.RAW_TEXT not in inputs:
348
+ raise OCRAdapterError(
349
+ f"{self.name} : input RAW_TEXT manquant.",
350
+ )
351
+ text_artifact = inputs[ArtifactType.RAW_TEXT]
352
+ if text_artifact.uri is None:
353
+ raise OCRAdapterError(
354
+ f"{self.name} : artefact RAW_TEXT "
355
+ f"{text_artifact.id!r} sans URI.",
356
+ )
357
+ text_path = Path(text_artifact.uri)
358
+ if not text_path.exists():
359
+ raise OCRAdapterError(
360
+ f"{self.name} : fichier texte introuvable {text_path!r}.",
361
+ )
362
+
363
+ original_text = text_path.read_text(encoding="utf-8")
364
+
365
+ # Image optionnelle (VLM-style si supporté).
366
+ image_b64: Optional[str] = None
367
+ image_artifact = inputs.get(ArtifactType.IMAGE)
368
+ if image_artifact is not None and image_artifact.uri is not None:
369
+ image_path = Path(image_artifact.uri)
370
+ if image_path.exists():
371
+ image_b64 = base64.b64encode(
372
+ image_path.read_bytes(),
373
+ ).decode("ascii")
374
+
375
+ prompt_template = self.config.get(
376
+ "correction_prompt", self.DEFAULT_CORRECTION_PROMPT,
377
+ )
378
+ prompt = prompt_template.format(text=original_text)
379
+
380
+ result = self.complete(prompt, image_b64=image_b64)
381
+ if not result.success:
382
+ raise OCRAdapterError(
383
+ f"{self.name} : LLM a échoué ({result.error}).",
384
+ )
385
+
386
+ out_path = (
387
+ text_path.parent / f"{text_path.stem}.{self.name}.corrected.txt"
388
+ )
389
+ out_path.write_text(result.text, encoding="utf-8")
390
+
391
+ return {
392
+ ArtifactType.CORRECTED_TEXT: Artifact(
393
+ id=f"{context.document_id}:{self.name}:corrected_text",
394
+ document_id=context.document_id,
395
+ type=ArtifactType.CORRECTED_TEXT,
396
+ produced_by_step="post_correction",
397
+ uri=str(out_path),
398
+ ),
399
+ }
400
+
401
  def __repr__(self) -> str:
402
  return f"{self.__class__.__name__}(model={self.model!r})"
403
 
tests/adapters/llm/__init__.py ADDED
File without changes
tests/adapters/llm/test_sprint_a14_s44_llm_step_executor.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S44 — ``BaseLLMAdapter`` implémente le contrat StepExecutor.
2
+
3
+ Tests de l'intégration native des 4 LLM adapters dans le pipeline :
4
+ ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``
5
+ ajouté à ``BaseLLMAdapter`` (sans wrapper / sans shim).
6
+
7
+ Couvre :
8
+ 1. ``BaseLLMAdapter.input_types`` / ``output_types`` / ``execution_mode``
9
+ 2. ``execute`` lit RAW_TEXT, appelle ``complete``, écrit
10
+ ``<stem>.<name>.corrected.txt``, retourne CORRECTED_TEXT.
11
+ 3. Erreurs : RAW_TEXT manquant, sans URI, fichier inexistant,
12
+ complete() en échec.
13
+ 4. Image optionnelle : ``inputs[IMAGE]`` est encodée en base64 et
14
+ passée au ``complete``.
15
+ 5. Les 4 adapters concrets (Anthropic, Mistral, OpenAI, Ollama)
16
+ héritent bien du contrat.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+
26
+ from picarones.adapters.llm.base import BaseLLMAdapter
27
+ from picarones.adapters.ocr.base import OCRAdapterError
28
+ from picarones.domain.artifacts import Artifact, ArtifactType
29
+ from picarones.pipeline.types import RunContext
30
+
31
+
32
+ # ──────────────────────────────────────────────────────────────────────
33
+ # Adapter de test concret
34
+ # ──────────────────────────────────────────────────────────────────────
35
+
36
+
37
+ class _StubLLMAdapter(BaseLLMAdapter):
38
+ """LLM stub pour tester ``execute`` sans appeler une vraie API."""
39
+
40
+ @property
41
+ def name(self) -> str:
42
+ return "stub_llm"
43
+
44
+ @property
45
+ def default_model(self) -> str:
46
+ return "stub-model-1.0"
47
+
48
+ def __init__(
49
+ self,
50
+ response_text: str = "TEXTE CORRIGÉ",
51
+ raise_on_call: bool = False,
52
+ model=None,
53
+ config=None,
54
+ ) -> None:
55
+ super().__init__(model=model, config=config)
56
+ self._response = response_text
57
+ self._raise = raise_on_call
58
+ self.last_prompt = None
59
+ self.last_image_b64 = None
60
+
61
+ def _call(self, prompt, image_b64=None):
62
+ self.last_prompt = prompt
63
+ self.last_image_b64 = image_b64
64
+ if self._raise:
65
+ raise RuntimeError("LLM crashed")
66
+ return self._response
67
+
68
+
69
+ def _make_context() -> RunContext:
70
+ return RunContext(
71
+ document_id="doc01",
72
+ code_version="1.0.0",
73
+ pipeline_name="test",
74
+ )
75
+
76
+
77
+ def _make_text_artifact(uri: str) -> Artifact:
78
+ return Artifact(
79
+ id="doc01:ocr:raw_text",
80
+ document_id="doc01",
81
+ type=ArtifactType.RAW_TEXT,
82
+ uri=uri,
83
+ )
84
+
85
+
86
+ def _make_image_artifact(uri: str) -> Artifact:
87
+ return Artifact(
88
+ id="doc01:image",
89
+ document_id="doc01",
90
+ type=ArtifactType.IMAGE,
91
+ uri=uri,
92
+ )
93
+
94
+
95
+ # ──────────────────────────────────────────────────────────────────────
96
+ # Contract StepExecutor
97
+ # ──────────────────────────────────────────────────────────────────────
98
+
99
+
100
+ class TestBaseLLMAdapterContract:
101
+ def test_input_types_default_raw_text(self) -> None:
102
+ adapter = _StubLLMAdapter()
103
+ assert ArtifactType.RAW_TEXT in adapter.input_types
104
+
105
+ def test_output_types_default_corrected_text(self) -> None:
106
+ adapter = _StubLLMAdapter()
107
+ assert ArtifactType.CORRECTED_TEXT in adapter.output_types
108
+
109
+ def test_execution_mode_default_io(self) -> None:
110
+ # Class attribute, pas instance.
111
+ assert BaseLLMAdapter.execution_mode == "io"
112
+
113
+
114
+ # ──────────────────────────────────────────────────────────────────────
115
+ # execute() — chemin nominal
116
+ # ──────────────────────────────────────────────────────────────────────
117
+
118
+
119
+ class TestLLMExecuteNominal:
120
+ def test_basic_correction(self, tmp_path: Path) -> None:
121
+ text_path = tmp_path / "doc01.txt"
122
+ text_path.write_text("texte avec erreurs", encoding="utf-8")
123
+
124
+ adapter = _StubLLMAdapter(response_text="texte sans erreurs")
125
+ result = adapter.execute(
126
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
127
+ params={},
128
+ context=_make_context(),
129
+ )
130
+ assert ArtifactType.CORRECTED_TEXT in result
131
+ produced = result[ArtifactType.CORRECTED_TEXT]
132
+ assert produced.type == ArtifactType.CORRECTED_TEXT
133
+ assert produced.document_id == "doc01"
134
+
135
+ out_path = Path(produced.uri)
136
+ assert out_path.exists()
137
+ assert out_path.read_text(encoding="utf-8") == "texte sans erreurs"
138
+ assert out_path.name == "doc01.stub_llm.corrected.txt"
139
+
140
+ def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None:
141
+ text_path = tmp_path / "doc01.txt"
142
+ text_path.write_text("x", encoding="utf-8")
143
+ adapter = _StubLLMAdapter()
144
+ result = adapter.execute(
145
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
146
+ params={},
147
+ context=_make_context(),
148
+ )
149
+ produced = result[ArtifactType.CORRECTED_TEXT]
150
+ assert produced.id == "doc01:stub_llm:corrected_text"
151
+ assert produced.produced_by_step == "post_correction"
152
+
153
+ def test_prompt_template_formatted_with_text(self, tmp_path: Path) -> None:
154
+ text_path = tmp_path / "doc01.txt"
155
+ text_path.write_text("input text", encoding="utf-8")
156
+ adapter = _StubLLMAdapter()
157
+ adapter.execute(
158
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
159
+ params={},
160
+ context=_make_context(),
161
+ )
162
+ # Le prompt doit contenir le texte d'entrée.
163
+ assert "input text" in adapter.last_prompt
164
+
165
+ def test_custom_prompt_via_config(self, tmp_path: Path) -> None:
166
+ text_path = tmp_path / "doc01.txt"
167
+ text_path.write_text("input", encoding="utf-8")
168
+ adapter = _StubLLMAdapter(config={
169
+ "correction_prompt": "Custom: {text}",
170
+ })
171
+ adapter.execute(
172
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
173
+ params={},
174
+ context=_make_context(),
175
+ )
176
+ assert adapter.last_prompt == "Custom: input"
177
+
178
+
179
+ # ──────────────────────────────────────────────────────────────────────
180
+ # Erreurs
181
+ # ──────────────────────────────────────────────────────────────────────
182
+
183
+
184
+ class TestLLMExecuteErrors:
185
+ def test_missing_raw_text_raises(self) -> None:
186
+ adapter = _StubLLMAdapter()
187
+ with pytest.raises(OCRAdapterError, match="RAW_TEXT manquant"):
188
+ adapter.execute(
189
+ inputs={},
190
+ params={},
191
+ context=_make_context(),
192
+ )
193
+
194
+ def test_text_artifact_without_uri_raises(self) -> None:
195
+ adapter = _StubLLMAdapter()
196
+ artifact = Artifact(
197
+ id="x",
198
+ document_id="doc01",
199
+ type=ArtifactType.RAW_TEXT,
200
+ uri=None,
201
+ )
202
+ with pytest.raises(OCRAdapterError, match="sans URI"):
203
+ adapter.execute(
204
+ inputs={ArtifactType.RAW_TEXT: artifact},
205
+ params={},
206
+ context=_make_context(),
207
+ )
208
+
209
+ def test_text_path_not_existing_raises(self) -> None:
210
+ adapter = _StubLLMAdapter()
211
+ with pytest.raises(OCRAdapterError, match="introuvable"):
212
+ adapter.execute(
213
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(
214
+ "/nonexistent/x.txt",
215
+ )},
216
+ params={},
217
+ context=_make_context(),
218
+ )
219
+
220
+ def test_llm_call_failing_raises(self, tmp_path: Path) -> None:
221
+ text_path = tmp_path / "x.txt"
222
+ text_path.write_text("x", encoding="utf-8")
223
+ adapter = _StubLLMAdapter(raise_on_call=True, config={
224
+ "max_retries": 0, # pas de retry pour accélérer le test
225
+ })
226
+ with pytest.raises(OCRAdapterError, match="LLM a échoué"):
227
+ adapter.execute(
228
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
229
+ params={},
230
+ context=_make_context(),
231
+ )
232
+
233
+
234
+ # ──────────────────────────────────────────────────────────────────────
235
+ # Image optionnelle (mode VLM)
236
+ # ──────────────────────────────────────────────────────────────────────
237
+
238
+
239
+ class TestLLMExecuteWithImage:
240
+ def test_image_passed_to_llm_as_base64(self, tmp_path: Path) -> None:
241
+ text_path = tmp_path / "doc.txt"
242
+ text_path.write_text("x", encoding="utf-8")
243
+ image_path = tmp_path / "doc.png"
244
+ image_path.write_bytes(b"PNGBYTES")
245
+
246
+ adapter = _StubLLMAdapter()
247
+ adapter.execute(
248
+ inputs={
249
+ ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path)),
250
+ ArtifactType.IMAGE: _make_image_artifact(str(image_path)),
251
+ },
252
+ params={},
253
+ context=_make_context(),
254
+ )
255
+ # L'image doit être encodée en base64.
256
+ assert adapter.last_image_b64 is not None
257
+ decoded = base64.b64decode(adapter.last_image_b64)
258
+ assert decoded == b"PNGBYTES"
259
+
260
+ def test_image_omitted_when_not_provided(self, tmp_path: Path) -> None:
261
+ text_path = tmp_path / "doc.txt"
262
+ text_path.write_text("x", encoding="utf-8")
263
+ adapter = _StubLLMAdapter()
264
+ adapter.execute(
265
+ inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
266
+ params={},
267
+ context=_make_context(),
268
+ )
269
+ assert adapter.last_image_b64 is None
270
+
271
+
272
+ # ──────────────────────────────────────────────────────────────────────
273
+ # Adapters concrets héritent du contrat
274
+ # ──────────────────────────────────────────────────────────────────────
275
+
276
+
277
+ class TestConcreteAdaptersInheritContract:
278
+ def test_openai_has_execute(self) -> None:
279
+ from picarones.adapters.llm.openai_adapter import OpenAIAdapter
280
+ # Vérifie que la méthode execute est héritée.
281
+ assert hasattr(OpenAIAdapter, "execute")
282
+ assert hasattr(OpenAIAdapter, "input_types")
283
+ assert hasattr(OpenAIAdapter, "output_types")
284
+
285
+ def test_anthropic_has_execute(self) -> None:
286
+ from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter
287
+ assert hasattr(AnthropicAdapter, "execute")
288
+
289
+ def test_mistral_has_execute(self) -> None:
290
+ from picarones.adapters.llm.mistral_adapter import MistralAdapter
291
+ assert hasattr(MistralAdapter, "execute")
292
+
293
+ def test_ollama_has_execute(self) -> None:
294
+ from picarones.adapters.llm.ollama_adapter import OllamaAdapter
295
+ assert hasattr(OllamaAdapter, "execute")
296
+
297
+
298
+ # ──────────────────────────────────────────────────────────────────────
299
+ # Intégration pipeline (utilisation comme StepExecutor)
300
+ # ──────────────────────────────────────────────────────────────────────
301
+
302
+
303
+ class TestPipelineIntegration:
304
+ def test_used_as_pipeline_step(self, tmp_path: Path) -> None:
305
+ """Un adapter LLM se branche directement comme step de pipeline."""
306
+ from picarones.pipeline.executor import PipelineExecutor
307
+ from picarones.pipeline.spec import PipelineSpec, PipelineStep
308
+ from picarones.domain.documents import DocumentRef
309
+
310
+ text_path = tmp_path / "doc01.txt"
311
+ text_path.write_text("input ocr", encoding="utf-8")
312
+
313
+ adapter = _StubLLMAdapter(response_text="cleaned text")
314
+ executor = PipelineExecutor(
315
+ adapter_resolver=lambda name: adapter,
316
+ )
317
+ spec = PipelineSpec(
318
+ name="post_correction",
319
+ initial_inputs=(ArtifactType.RAW_TEXT,),
320
+ steps=(
321
+ PipelineStep(
322
+ id="llm",
323
+ kind="post_correction",
324
+ adapter_name="stub_llm",
325
+ input_types=(ArtifactType.RAW_TEXT,),
326
+ output_types=(ArtifactType.CORRECTED_TEXT,),
327
+ ),
328
+ ),
329
+ )
330
+ result = executor.run(
331
+ spec=spec,
332
+ document=DocumentRef(id="doc01"),
333
+ initial_inputs={
334
+ ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path)),
335
+ },
336
+ context=_make_context(),
337
+ )
338
+ assert result.succeeded
339
+ # Trouve le CORRECTED_TEXT artefact.
340
+ corrected = [
341
+ a for a in result.artifacts
342
+ if a.type == ArtifactType.CORRECTED_TEXT
343
+ ]
344
+ assert len(corrected) == 1
tests/app/test_run_orchestrator.py CHANGED
@@ -156,9 +156,9 @@ class TestExecuteHappyPath:
156
  assert result.extracted_corpus_dir.resolve().is_relative_to(
157
  out_dir.resolve(),
158
  )
159
- # 3 fichiers persistés.
160
  assert set(result.persisted_files) == {
161
- "manifest", "pipeline_results", "view_results",
162
  }
163
  for path in result.persisted_files.values():
164
  assert path.exists()
 
156
  assert result.extracted_corpus_dir.resolve().is_relative_to(
157
  out_dir.resolve(),
158
  )
159
+ # S41 — 4 fichiers persistés (artifacts_index séparé).
160
  assert set(result.persisted_files) == {
161
+ "manifest", "pipeline_results", "artifacts_index", "view_results",
162
  }
163
  for path in result.persisted_files.values():
164
  assert path.exists()
tests/architecture/test_file_budgets.py CHANGED
@@ -88,6 +88,11 @@ FILE_BUDGETS: dict[str, int] = {
88
  # hash multi-paramètres pour adresser la critique d'audit n° 14
89
  # « hash multi-paramètres + reprise par hash ».
90
  "picarones/adapters/storage/artifact_store.py": 580, # actuel 504
 
 
 
 
 
91
  "picarones/core/corpus.py": 600, # actuel 511
92
  "picarones/fixtures.py": 600, # actuel 510
93
  "picarones/measurements/inter_engine.py": 575, # actuel 484
 
88
  # hash multi-paramètres pour adresser la critique d'audit n° 14
89
  # « hash multi-paramètres + reprise par hash ».
90
  "picarones/adapters/storage/artifact_store.py": 580, # actuel 504
91
+ # Sprint A14-S41 — artifacts_index.jsonl séparé.
92
+ "picarones/app/services/benchmark_service.py": 470, # actuel 400
93
+ # Sprint A14-S44 — BaseLLMAdapter implémente le contrat StepExecutor
94
+ # (input_types, output_types, execute) en plus de complete().
95
+ "picarones/adapters/llm/base.py": 475, # actuel 410
96
  "picarones/core/corpus.py": 600, # actuel 511
97
  "picarones/fixtures.py": 600, # actuel 510
98
  "picarones/measurements/inter_engine.py": 575, # actuel 484