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

feat(adapters/vlm): Sprint A14-S45 — 4 VLM adapters natifs (Phase 6 done)

Browse files

4 VLM adapters (Vision-Language Models) livrés natifs au contrat
StepExecutor : ils consomment IMAGE et produisent RAW_TEXT via prompt
multimodal, complémentaires aux 5 OCR dédiés (Phase 2).

picarones/adapters/vlm/base.py
------------------------------
BaseVLMAdapter hérite de BaseLLMAdapter et surcharge :
- input_types = {IMAGE} (override de BaseLLMAdapter qui demandait
RAW_TEXT) ;
- output_types = {RAW_TEXT} (override de CORRECTED_TEXT) ;
- DEFAULT_TRANSCRIPTION_PROMPT (configurable via
config["transcription_prompt"]) ;
- execute(inputs, params, context) :
· valide IMAGE input + URI + fichier existe → OCRAdapterError ;
· encode l'image en base64 ;
· appelle self.complete(prompt, image_b64) avec retry hérité ;
· si LLMResult.error → OCRAdapterError ;
· écrit dans <stem>.<name>.txt à côté de l'image ;
· retourne Artifact RAW_TEXT avec id "<doc>:<name>:raw_text",
produced_by_step="vlm_transcription".

4 adapters concrets via MRO multiple
------------------------------------
Chaque adapter VLM hérite à la fois de BaseVLMAdapter (contrat S45)
et de son LLM sibling (api_call, retry, validation API key) :

- AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter) : Claude
Sonnet/Opus avec vision.
- OpenAIVLMAdapter(BaseVLMAdapter, OpenAIAdapter) : gpt-4o,
gpt-4-turbo, gpt-4-vision-preview.
- MistralVLMAdapter(BaseVLMAdapter, MistralAdapter) : pixtral-12b-2409
(default override), pixtral-large-latest.
- OllamaVLMAdapter(BaseVLMAdapter, OllamaAdapter) : llava (default),
bakllava, llama3.2-vision (local).

L'ordre du MRO (BaseVLMAdapter d'abord) garantit que input_types,
output_types, execute() viennent de BaseVLMAdapter ; _call,
default_model (sauf override), retry, etc. viennent du sibling LLM.

Pas un shim
-----------
Les VLM adapters ne wrappent pas les LLM adapters ; ils étendent
le même provider avec un mode d'usage différent (vision vs texte)
via héritage multiple — chaque concret est first-class avec son
propre execute() et name.

Tests S45 dédiés (30 nouveaux)
------------------------------
- BaseVLMAdapterContract : input_types={IMAGE}, output_types=
{RAW_TEXT}, execution_mode="io".
- VLMExecuteNominal : transcription basique → fichier
<stem>.<name>.txt, image base64 passée au LLM, artifact id correct
avec produced_by_step="vlm_transcription", custom prompt via
config.
- VLMExecuteErrors : IMAGE manquant, sans URI, fichier inexistant,
VLM call failing → tous OCRAdapterError.
- ConcreteVLMAdapters (4 × 4 paramétrés) : chaque adapter
(Anthropic/OpenAI/Mistral/Ollama) a le bon name, input_types,
output_types, execute. Mistral default model contient "pixtral",
Ollama contient "llava".
- VLMPipelineIntegration : un VLM adapter se branche directement
comme step de pipeline (test bout-en-bout).

Tests : 4911 passed, 11 skipped (vs 4881 avant : +30 S45).
Lint : ruff check picarones/ tests/ → All checks passed.

Phase 6 récapitulatif
---------------------
| Sprint | Composant | Tests | Total |
|--------|--------------------------|-------|-------|
| S44 | BaseLLMAdapter execute() | 18 | +18 |
| S45 | 4 VLM adapters natifs | 30 | +30 |

Total Phase 6 : 48 nouveaux tests, 8 adapters LLM/VLM nativement
intégrés au pipeline.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/adapters/vlm/__init__.py CHANGED
@@ -1,20 +1,42 @@
1
- """Adaptateurs VLM (Vision-Language Models).
2
 
3
- Volontairement vide à la livraison du rewrite ciblé. Les VLM
4
- arrivent post-livraison une fois que le pattern d'adapter LLM est
5
- stabilisé et que les vues d'évaluation
6
- (``HallucinationView``, ``ReconstructionView``) sont en place pour
7
- les comparer honnêtement avec les pipelines OCR+LLM (cf.
8
- ``BACKLOG_POST_LIVRAISON.md`` §2.2).
9
 
10
- Cibles à terme : Qwen-VL, Gemini Vision, GPT-4o Vision, Claude
11
- Sonnet/Opus Vision, Pixtral.
 
 
 
 
12
 
13
- Note : un VLM peut produire ``RAW_TEXT`` ou ``CANONICAL_DOCUMENT``
14
- selon le mode (zero-shot transcription vs. document understanding).
15
- Le pipeline le branche selon le besoin de l'expérience.
 
 
 
 
 
 
 
 
16
  """
17
 
18
  from __future__ import annotations
19
 
20
- __all__: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adapters VLM (Vision-Language Models) — Sprint A14-S45.
2
 
3
+ VLM = transcription directe par un modèle généraliste avec vision.
4
+ Distinct des OCR dédiés (Tesseract, Pero, Mistral OCR, Google Vision,
5
+ Azure DI) — un VLM consomme IMAGE et produit RAW_TEXT via prompt
6
+ multimodal, sans layout structuré natif.
 
 
7
 
8
+ Adapters livrés
9
+ ---------------
10
+ - ``AnthropicVLMAdapter`` : Claude Sonnet/Opus avec vision.
11
+ - ``OpenAIVLMAdapter`` : GPT-4o, GPT-4-turbo, GPT-4-vision-preview.
12
+ - ``MistralVLMAdapter`` : Pixtral 12b/Large.
13
+ - ``OllamaVLMAdapter`` : LLaVA, BakLLaVA, llama3.2-vision (local).
14
 
15
+ Convention StepExecutor :
16
+
17
+ - ``input_types = {IMAGE}``
18
+ - ``output_types = {RAW_TEXT}``
19
+ - ``execute(inputs, params, context)`` encode l'image en base64,
20
+ appelle le LLM avec un prompt de transcription, écrit le texte
21
+ produit dans ``<stem>.<adapter_name>.txt`` à côté de l'image,
22
+ retourne un Artifact RAW_TEXT.
23
+
24
+ Pas un shim sur les LLM adapters : c'est un mode d'usage
25
+ distinct (vision vs texte) avec un contrat StepExecutor différent.
26
  """
27
 
28
  from __future__ import annotations
29
 
30
+ from picarones.adapters.vlm.anthropic_vlm import AnthropicVLMAdapter
31
+ from picarones.adapters.vlm.base import BaseVLMAdapter
32
+ from picarones.adapters.vlm.mistral_vlm import MistralVLMAdapter
33
+ from picarones.adapters.vlm.ollama_vlm import OllamaVLMAdapter
34
+ from picarones.adapters.vlm.openai_vlm import OpenAIVLMAdapter
35
+
36
+ __all__ = [
37
+ "BaseVLMAdapter",
38
+ "AnthropicVLMAdapter",
39
+ "MistralVLMAdapter",
40
+ "OllamaVLMAdapter",
41
+ "OpenAIVLMAdapter",
42
+ ]
picarones/adapters/vlm/anthropic_vlm.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``AnthropicVLMAdapter`` — Claude Sonnet/Opus en mode vision.
2
+
3
+ Sprint A14-S45. Délègue l'appel API au mécanisme de
4
+ ``AnthropicAdapter`` (qui supporte déjà la vision via le SDK
5
+ anthropic) en surchargeant le contrat StepExecutor pour consommer
6
+ IMAGE au lieu de RAW_TEXT.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter
12
+ from picarones.adapters.vlm.base import BaseVLMAdapter
13
+
14
+
15
+ class AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter):
16
+ """VLM Claude (Sonnet/Opus avec vision).
17
+
18
+ L'ordre du MRO est important : ``BaseVLMAdapter`` d'abord pour
19
+ surcharger ``input_types``/``output_types``/``execute``, puis
20
+ ``AnthropicAdapter`` pour ``_call``/``default_model``/``name``/
21
+ retry/validation API key.
22
+
23
+ Modèles vision recommandés : ``claude-3-5-sonnet-latest``,
24
+ ``claude-3-opus-latest``.
25
+ """
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ return "anthropic_vlm"
30
+
31
+
32
+ __all__ = ["AnthropicVLMAdapter"]
picarones/adapters/vlm/base.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``BaseVLMAdapter`` — Sprint A14-S45.
2
+
3
+ Adapter VLM (Vision-Language Model) qui hérite de ``BaseLLMAdapter``
4
+ et surcharge le contrat StepExecutor pour consommer ``IMAGE`` au
5
+ lieu de ``RAW_TEXT`` et produire ``RAW_TEXT`` (transcription
6
+ directe par un VLM).
7
+
8
+ Pas un shim sur les LLM adapters : c'est un mode d'usage différent
9
+ de la même API LLM (texte vs image) — le contrat StepExecutor diffère.
10
+
11
+ Différences avec ``BaseOCRAdapter`` (S26)
12
+ -----------------------------------------
13
+ - Un OCR (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI)
14
+ utilise des modèles dédiés OCR avec layout structuré, confidences
15
+ natives, etc.
16
+ - Un VLM (Anthropic Claude, GPT-4-Vision, Pixtral, LLaVA) fait de la
17
+ transcription via un modèle généraliste prompt+image.
18
+
19
+ Les deux peuvent produire RAW_TEXT et être comparés en TextView ;
20
+ la projection report explicitera ce qu'on perd côté VLM (pas de
21
+ coordonnées spatiales nativement).
22
+
23
+ Convention output : RAW_TEXT (transcription plate). Une sous-classe
24
+ qui produit du markdown structuré (ex. ``CANONICAL_DOCUMENT``) peut
25
+ surcharger ``output_types``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ import logging
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from picarones.adapters.llm.base import BaseLLMAdapter
36
+ from picarones.adapters.ocr.base import OCRAdapterError
37
+ from picarones.domain.artifacts import Artifact, ArtifactType
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class BaseVLMAdapter(BaseLLMAdapter):
43
+ """Adapter VLM qui transcrit une IMAGE en RAW_TEXT.
44
+
45
+ Hérite de ``BaseLLMAdapter`` et surcharge le contrat
46
+ ``StepExecutor`` pour consommer ``IMAGE`` au lieu de ``RAW_TEXT``.
47
+
48
+ Parameters
49
+ ----------
50
+ model:
51
+ Modèle VLM (cf. sous-classes pour les défauts).
52
+ config:
53
+ Config dict ; supporte
54
+ ``config["transcription_prompt"]`` pour personnaliser le
55
+ prompt de transcription.
56
+ """
57
+
58
+ @property
59
+ def input_types(self) -> "frozenset":
60
+ return frozenset({ArtifactType.IMAGE})
61
+
62
+ @property
63
+ def output_types(self) -> "frozenset":
64
+ return frozenset({ArtifactType.RAW_TEXT})
65
+
66
+ DEFAULT_TRANSCRIPTION_PROMPT: str = (
67
+ "Transcris fidèlement le texte visible sur cette image de "
68
+ "document historique. Conserve l'orthographe historique, les "
69
+ "abréviations, et la ponctuation. Retourne uniquement le "
70
+ "texte transcrit, sans commentaire."
71
+ )
72
+
73
+ def execute(
74
+ self,
75
+ inputs: dict,
76
+ params: dict,
77
+ context: Any,
78
+ ) -> dict:
79
+ """Exécute la transcription VLM.
80
+
81
+ Lit ``inputs[IMAGE]`` (URI), encode en base64, appelle
82
+ ``self.complete(prompt, image_b64)``, écrit le résultat
83
+ dans ``<stem>.<name>.txt`` à côté de l'image, et retourne
84
+ ``{RAW_TEXT: Artifact}``.
85
+ """
86
+ if ArtifactType.IMAGE not in inputs:
87
+ raise OCRAdapterError(
88
+ f"{self.name} : input IMAGE manquant.",
89
+ )
90
+ image_artifact = inputs[ArtifactType.IMAGE]
91
+ if image_artifact.uri is None:
92
+ raise OCRAdapterError(
93
+ f"{self.name} : artefact image "
94
+ f"{image_artifact.id!r} sans URI.",
95
+ )
96
+ image_path = Path(image_artifact.uri)
97
+ if not image_path.exists():
98
+ raise OCRAdapterError(
99
+ f"{self.name} : image introuvable {image_path!r}.",
100
+ )
101
+
102
+ image_b64 = base64.b64encode(
103
+ image_path.read_bytes(),
104
+ ).decode("ascii")
105
+
106
+ prompt = self.config.get(
107
+ "transcription_prompt", self.DEFAULT_TRANSCRIPTION_PROMPT,
108
+ )
109
+
110
+ result = self.complete(prompt, image_b64=image_b64)
111
+ if not result.success:
112
+ raise OCRAdapterError(
113
+ f"{self.name} : VLM a échoué ({result.error}).",
114
+ )
115
+
116
+ out_path = (
117
+ image_path.parent / f"{image_path.stem}.{self.name}.txt"
118
+ )
119
+ out_path.write_text(result.text, encoding="utf-8")
120
+
121
+ return {
122
+ ArtifactType.RAW_TEXT: Artifact(
123
+ id=f"{context.document_id}:{self.name}:raw_text",
124
+ document_id=context.document_id,
125
+ type=ArtifactType.RAW_TEXT,
126
+ produced_by_step="vlm_transcription",
127
+ uri=str(out_path),
128
+ ),
129
+ }
130
+
131
+
132
+ __all__ = ["BaseVLMAdapter"]
picarones/adapters/vlm/mistral_vlm.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``MistralVLMAdapter`` — Pixtral 12b/Large (vision Mistral).
2
+
3
+ Sprint A14-S45. Délègue à ``MistralAdapter`` qui supporte la
4
+ vision via les modèles ``pixtral-12b-2409``, ``pixtral-large-latest``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from picarones.adapters.llm.mistral_adapter import MistralAdapter
10
+ from picarones.adapters.vlm.base import BaseVLMAdapter
11
+
12
+
13
+ class MistralVLMAdapter(BaseVLMAdapter, MistralAdapter):
14
+ """VLM Mistral (pixtral-12b-2409, pixtral-large-latest)."""
15
+
16
+ @property
17
+ def name(self) -> str:
18
+ return "mistral_vlm"
19
+
20
+ @property
21
+ def default_model(self) -> str:
22
+ # Ré-définit le défaut pour pointer vers un modèle vision.
23
+ return "pixtral-12b-2409"
24
+
25
+
26
+ __all__ = ["MistralVLMAdapter"]
picarones/adapters/vlm/ollama_vlm.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``OllamaVLMAdapter`` — Modèles vision locaux via Ollama.
2
+
3
+ Sprint A14-S45. Délègue à ``OllamaAdapter`` (local, sans clé API).
4
+ Modèles vision recommandés : ``llava``, ``llava:13b``, ``bakllava``,
5
+ ``llama3.2-vision``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from picarones.adapters.llm.ollama_adapter import OllamaAdapter
11
+ from picarones.adapters.vlm.base import BaseVLMAdapter
12
+
13
+
14
+ class OllamaVLMAdapter(BaseVLMAdapter, OllamaAdapter):
15
+ """VLM local via Ollama (llava, bakllava, llama3.2-vision)."""
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ return "ollama_vlm"
20
+
21
+ @property
22
+ def default_model(self) -> str:
23
+ return "llava"
24
+
25
+
26
+ __all__ = ["OllamaVLMAdapter"]
picarones/adapters/vlm/openai_vlm.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``OpenAIVLMAdapter`` — GPT-4-Vision / GPT-4o (vision).
2
+
3
+ Sprint A14-S45. Délègue à ``OpenAIAdapter`` qui supporte déjà la
4
+ vision via les modèles ``gpt-4o``, ``gpt-4-turbo``,
5
+ ``gpt-4-vision-preview``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from picarones.adapters.llm.openai_adapter import OpenAIAdapter
11
+ from picarones.adapters.vlm.base import BaseVLMAdapter
12
+
13
+
14
+ class OpenAIVLMAdapter(BaseVLMAdapter, OpenAIAdapter):
15
+ """VLM OpenAI (gpt-4o, gpt-4-turbo, gpt-4-vision-preview)."""
16
+
17
+ @property
18
+ def name(self) -> str:
19
+ return "openai_vlm"
20
+
21
+
22
+ __all__ = ["OpenAIVLMAdapter"]
tests/adapters/vlm/__init__.py ADDED
File without changes
tests/adapters/vlm/test_sprint_a14_s45_vlm_adapters.py ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S45 — VLM adapters (4 fournisseurs).
2
+
3
+ Tests des 4 adapters VLM qui héritent de ``BaseVLMAdapter`` +
4
+ leur LLM sibling (composition par MRO multiple).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ from picarones.adapters.ocr.base import OCRAdapterError
15
+ from picarones.adapters.vlm import (
16
+ AnthropicVLMAdapter,
17
+ BaseVLMAdapter,
18
+ MistralVLMAdapter,
19
+ OllamaVLMAdapter,
20
+ OpenAIVLMAdapter,
21
+ )
22
+ from picarones.domain.artifacts import Artifact, ArtifactType
23
+ from picarones.pipeline.types import RunContext
24
+
25
+
26
+ # ──────────────────────────────────────────────────────────────────────
27
+ # Helpers
28
+ # ──────────────────────────────────────────────────────────────────────
29
+
30
+
31
+ class _StubVLMAdapter(BaseVLMAdapter):
32
+ """VLM stub pour tests : retourne un texte fixe."""
33
+
34
+ def __init__(
35
+ self,
36
+ response_text="texte transcrit",
37
+ raise_on_call=False,
38
+ config=None,
39
+ ):
40
+ super().__init__(config=config or {"max_retries": 0})
41
+ self._response = response_text
42
+ self._raise = raise_on_call
43
+ self.last_image_b64 = None
44
+
45
+ @property
46
+ def name(self) -> str:
47
+ return "stub_vlm"
48
+
49
+ @property
50
+ def default_model(self) -> str:
51
+ return "stub-vlm-1.0"
52
+
53
+ def _call(self, prompt, image_b64=None):
54
+ self.last_image_b64 = image_b64
55
+ if self._raise:
56
+ raise RuntimeError("VLM crashed")
57
+ return self._response
58
+
59
+
60
+ def _make_image_artifact(uri: str) -> Artifact:
61
+ return Artifact(
62
+ id="doc01:image",
63
+ document_id="doc01",
64
+ type=ArtifactType.IMAGE,
65
+ uri=uri,
66
+ )
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
+ # ──────────────────────────────────────────────────────────────────────
78
+ # Contrat StepExecutor (BaseVLMAdapter)
79
+ # ──────────────────────────────────────────────────────────────────────
80
+
81
+
82
+ class TestBaseVLMAdapterContract:
83
+ def test_input_types_is_image(self) -> None:
84
+ adapter = _StubVLMAdapter()
85
+ assert adapter.input_types == frozenset({ArtifactType.IMAGE})
86
+
87
+ def test_output_types_is_raw_text(self) -> None:
88
+ adapter = _StubVLMAdapter()
89
+ assert adapter.output_types == frozenset({ArtifactType.RAW_TEXT})
90
+
91
+ def test_execution_mode_is_io(self) -> None:
92
+ # Hérité de BaseLLMAdapter.
93
+ assert _StubVLMAdapter.execution_mode == "io"
94
+
95
+
96
+ class TestVLMExecuteNominal:
97
+ def test_basic_transcription(self, tmp_path: Path) -> None:
98
+ image_path = tmp_path / "doc01.png"
99
+ image_path.write_bytes(b"PNGBYTES")
100
+ adapter = _StubVLMAdapter(response_text="ceci est le texte")
101
+
102
+ result = adapter.execute(
103
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
104
+ params={},
105
+ context=_make_context(),
106
+ )
107
+ assert ArtifactType.RAW_TEXT in result
108
+ produced = result[ArtifactType.RAW_TEXT]
109
+ assert produced.type == ArtifactType.RAW_TEXT
110
+ assert produced.document_id == "doc01"
111
+ out_path = Path(produced.uri)
112
+ assert out_path.exists()
113
+ assert out_path.read_text(encoding="utf-8") == "ceci est le texte"
114
+ assert out_path.name == "doc01.stub_vlm.txt"
115
+
116
+ def test_image_passed_to_llm_as_base64(self, tmp_path: Path) -> None:
117
+ image_path = tmp_path / "doc01.png"
118
+ image_path.write_bytes(b"VLM_TEST_BYTES")
119
+ adapter = _StubVLMAdapter()
120
+ adapter.execute(
121
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
122
+ params={},
123
+ context=_make_context(),
124
+ )
125
+ decoded = base64.b64decode(adapter.last_image_b64)
126
+ assert decoded == b"VLM_TEST_BYTES"
127
+
128
+ def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None:
129
+ image_path = tmp_path / "doc01.png"
130
+ image_path.write_bytes(b"x")
131
+ adapter = _StubVLMAdapter()
132
+ result = adapter.execute(
133
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
134
+ params={},
135
+ context=_make_context(),
136
+ )
137
+ produced = result[ArtifactType.RAW_TEXT]
138
+ assert produced.id == "doc01:stub_vlm:raw_text"
139
+ assert produced.produced_by_step == "vlm_transcription"
140
+
141
+ def test_custom_transcription_prompt(self, tmp_path: Path) -> None:
142
+ image_path = tmp_path / "doc01.png"
143
+ image_path.write_bytes(b"x")
144
+ adapter = _StubVLMAdapter(config={
145
+ "max_retries": 0,
146
+ "transcription_prompt": "Custom VLM prompt",
147
+ })
148
+ # On capture le prompt en surchargeant _call.
149
+ captured = {}
150
+
151
+ def _capture_call(prompt, image_b64=None):
152
+ captured["prompt"] = prompt
153
+ return "x"
154
+
155
+ adapter._call = _capture_call # type: ignore[method-assign]
156
+ adapter.execute(
157
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
158
+ params={},
159
+ context=_make_context(),
160
+ )
161
+ assert captured["prompt"] == "Custom VLM prompt"
162
+
163
+
164
+ # ──────────────────────────────────────────────────────────────────────
165
+ # Erreurs
166
+ # ──────────────────────────────────────────────────────────────────────
167
+
168
+
169
+ class TestVLMExecuteErrors:
170
+ def test_missing_image_raises(self) -> None:
171
+ adapter = _StubVLMAdapter()
172
+ with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
173
+ adapter.execute(inputs={}, params={}, context=_make_context())
174
+
175
+ def test_image_without_uri_raises(self) -> None:
176
+ adapter = _StubVLMAdapter()
177
+ artifact = Artifact(
178
+ id="x",
179
+ document_id="doc01",
180
+ type=ArtifactType.IMAGE,
181
+ uri=None,
182
+ )
183
+ with pytest.raises(OCRAdapterError, match="sans URI"):
184
+ adapter.execute(
185
+ inputs={ArtifactType.IMAGE: artifact},
186
+ params={},
187
+ context=_make_context(),
188
+ )
189
+
190
+ def test_image_path_not_existing_raises(self) -> None:
191
+ adapter = _StubVLMAdapter()
192
+ with pytest.raises(OCRAdapterError, match="introuvable"):
193
+ adapter.execute(
194
+ inputs={ArtifactType.IMAGE: _make_image_artifact(
195
+ "/nonexistent/img.png",
196
+ )},
197
+ params={},
198
+ context=_make_context(),
199
+ )
200
+
201
+ def test_vlm_call_failing_raises(self, tmp_path: Path) -> None:
202
+ image_path = tmp_path / "doc.png"
203
+ image_path.write_bytes(b"x")
204
+ adapter = _StubVLMAdapter(raise_on_call=True)
205
+ with pytest.raises(OCRAdapterError, match="VLM a échoué"):
206
+ adapter.execute(
207
+ inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
208
+ params={},
209
+ context=_make_context(),
210
+ )
211
+
212
+
213
+ # ──────────────────────────────────────────────────────────────────────
214
+ # Adapters concrets — héritage MRO
215
+ # ──────────────────────────────────────────────────────────────────────
216
+
217
+
218
+ class TestConcreteVLMAdapters:
219
+ @pytest.mark.parametrize("adapter_cls,expected_name", [
220
+ (AnthropicVLMAdapter, "anthropic_vlm"),
221
+ (OpenAIVLMAdapter, "openai_vlm"),
222
+ (MistralVLMAdapter, "mistral_vlm"),
223
+ (OllamaVLMAdapter, "ollama_vlm"),
224
+ ])
225
+ def test_adapter_name(self, adapter_cls, expected_name) -> None:
226
+ adapter = adapter_cls()
227
+ assert adapter.name == expected_name
228
+
229
+ @pytest.mark.parametrize("adapter_cls", [
230
+ AnthropicVLMAdapter,
231
+ OpenAIVLMAdapter,
232
+ MistralVLMAdapter,
233
+ OllamaVLMAdapter,
234
+ ])
235
+ def test_adapter_input_types(self, adapter_cls) -> None:
236
+ # input_types vient de BaseVLMAdapter par MRO.
237
+ adapter = adapter_cls()
238
+ assert adapter.input_types == frozenset({ArtifactType.IMAGE})
239
+
240
+ @pytest.mark.parametrize("adapter_cls", [
241
+ AnthropicVLMAdapter,
242
+ OpenAIVLMAdapter,
243
+ MistralVLMAdapter,
244
+ OllamaVLMAdapter,
245
+ ])
246
+ def test_adapter_output_types(self, adapter_cls) -> None:
247
+ adapter = adapter_cls()
248
+ assert adapter.output_types == frozenset({ArtifactType.RAW_TEXT})
249
+
250
+ @pytest.mark.parametrize("adapter_cls", [
251
+ AnthropicVLMAdapter,
252
+ OpenAIVLMAdapter,
253
+ MistralVLMAdapter,
254
+ OllamaVLMAdapter,
255
+ ])
256
+ def test_adapter_has_execute(self, adapter_cls) -> None:
257
+ # execute() vient de BaseVLMAdapter par MRO.
258
+ assert hasattr(adapter_cls, "execute")
259
+
260
+ def test_mistral_default_model_is_pixtral(self) -> None:
261
+ adapter = MistralVLMAdapter()
262
+ assert "pixtral" in adapter.default_model.lower()
263
+
264
+ def test_ollama_default_model_is_vision_capable(self) -> None:
265
+ adapter = OllamaVLMAdapter()
266
+ # Modèle par défaut doit être un modèle vision (llava family).
267
+ assert "llava" in adapter.default_model.lower()
268
+
269
+
270
+ # ──────────────────────────────────────────────────────────────────────
271
+ # Intégration pipeline (utilisation comme StepExecutor)
272
+ # ──────────────────────────────────────────────────────────────────────
273
+
274
+
275
+ class TestVLMPipelineIntegration:
276
+ def test_used_as_pipeline_step(self, tmp_path: Path) -> None:
277
+ from picarones.pipeline.executor import PipelineExecutor
278
+ from picarones.pipeline.spec import PipelineSpec, PipelineStep
279
+ from picarones.domain.documents import DocumentRef
280
+
281
+ image_path = tmp_path / "doc01.png"
282
+ image_path.write_bytes(b"PNG_BYTES")
283
+
284
+ adapter = _StubVLMAdapter(response_text="VLM transcription")
285
+ executor = PipelineExecutor(adapter_resolver=lambda name: adapter)
286
+ spec = PipelineSpec(
287
+ name="vlm_pipeline",
288
+ initial_inputs=(ArtifactType.IMAGE,),
289
+ steps=(
290
+ PipelineStep(
291
+ id="vlm",
292
+ kind="vlm_transcription",
293
+ adapter_name="stub_vlm",
294
+ input_types=(ArtifactType.IMAGE,),
295
+ output_types=(ArtifactType.RAW_TEXT,),
296
+ ),
297
+ ),
298
+ )
299
+ result = executor.run(
300
+ spec=spec,
301
+ document=DocumentRef(id="doc01"),
302
+ initial_inputs={
303
+ ArtifactType.IMAGE: _make_image_artifact(str(image_path)),
304
+ },
305
+ context=_make_context(),
306
+ )
307
+ assert result.succeeded
308
+ raw_text_artifacts = [
309
+ a for a in result.artifacts
310
+ if a.type == ArtifactType.RAW_TEXT
311
+ ]
312
+ assert len(raw_text_artifacts) == 1
313
+ out_path = Path(raw_text_artifacts[0].uri)
314
+ assert out_path.read_text(encoding="utf-8") == "VLM transcription"