Claude commited on
Commit
f662601
·
unverified ·
1 Parent(s): be431e3

feat(domain): Sprint A14-S52 — hiérarchie d'erreurs unifiée (fix audit #7 + #11)

Browse files

Avant S52 :
- BaseLLMAdapter.execute levait OCRAdapterError (sémantiquement faux).
- BaseVLMAdapter.execute levait OCRAdapterError (sémantiquement faux).
- JobStoreError héritait de Exception (un caller `except
PicaronesError` ratait silencieusement les erreurs JobStore).
- Pas de racine commune pour catcher 'toute erreur d'adapter'.

Après S52 :

picarones/domain/errors.py
--------------------------
Nouvelle classe AdapterStepError(PicaronesError) — racine commune
des 3 sous-classes d'adapter (OCR/LLM/VLM).

picarones/adapters/ocr/base.py
------------------------------
OCRAdapterError hérite désormais de AdapterStepError au lieu de
PicaronesError directement (pas de breaking change pour les callers
qui catchaient PicaronesError).

picarones/adapters/llm/base.py
------------------------------
- Nouvelle classe LLMAdapterError(AdapterStepError) exportée.
- BaseLLMAdapter.execute lève LLMAdapterError (au lieu de
OCRAdapterError importé depuis ocr.base).
- Suppression de l'import croisé ocr.base → llm.

picarones/adapters/vlm/base.py
------------------------------
- Nouvelle classe VLMAdapterError(AdapterStepError) exportée.
- BaseVLMAdapter.execute lève VLMAdapterError.

picarones/adapters/storage/job_store.py
---------------------------------------
JobStoreError hérite désormais de PicaronesError (au lieu de
Exception). Un caller `except PicaronesError` attrape désormais
les erreurs de persistance jobs.

Tests S52 (9 nouveaux)
----------------------
- TestErrorInheritance : OCR/LLM/VLM AdapterErrors héritent de
AdapterStepError ET de PicaronesError ; JobStoreError de
PicaronesError.
- TestPolymorphicCatch : except AdapterStepError attrape les 3
sous-classes ; except PicaronesError attrape tout (y compris
JobStoreError).

Migration tests existants
-------------------------
- tests/adapters/llm/test_sprint_a14_s44 : OCRAdapterError →
LLMAdapterError (4 substitutions).
- tests/adapters/vlm/test_sprint_a14_s45 : OCRAdapterError →
VLMAdapterError (4 substitutions).

Tests : 268 passed dans tests/adapters/ + tests/domain/, 0
régression.
Lint : All checks passed.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/adapters/llm/base.py CHANGED
@@ -138,6 +138,22 @@ def log_http_error(
138
  )
139
 
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  @dataclass
142
  class LLMResult:
143
  """Résultat produit par un appel LLM."""
@@ -341,22 +357,21 @@ class BaseLLMAdapter(ABC):
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
 
@@ -379,7 +394,7 @@ class BaseLLMAdapter(ABC):
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
 
@@ -411,6 +426,7 @@ class BaseLLMAdapter(ABC):
411
 
412
  __all__ = [
413
  "BaseLLMAdapter",
 
414
  "LLMResult",
415
  "log_http_error",
416
  "normalize_llm_content",
 
138
  )
139
 
140
 
141
+ from picarones.domain.errors import AdapterStepError
142
+
143
+
144
+ class LLMAdapterError(AdapterStepError):
145
+ """Erreur typée pour un échec d'adapter LLM (Sprint S52).
146
+
147
+ Hérite de ``AdapterStepError`` (commune avec OCR et VLM) → un
148
+ caller peut catcher ``AdapterStepError`` pour toute erreur
149
+ d'adapter sans connaître la sous-classe.
150
+
151
+ Avant S52, ``BaseLLMAdapter.execute`` levait ``OCRAdapterError``
152
+ par confusion sémantique — c'était noté dans l'audit comme issue
153
+ #11 (hiérarchie incohérente).
154
+ """
155
+
156
+
157
  @dataclass
158
  class LLMResult:
159
  """Résultat produit par un appel LLM."""
 
357
  from pathlib import Path
358
  import base64
359
 
 
360
  from picarones.domain.artifacts import Artifact, ArtifactType
361
 
362
  if ArtifactType.RAW_TEXT not in inputs:
363
+ raise LLMAdapterError(
364
  f"{self.name} : input RAW_TEXT manquant.",
365
  )
366
  text_artifact = inputs[ArtifactType.RAW_TEXT]
367
  if text_artifact.uri is None:
368
+ raise LLMAdapterError(
369
  f"{self.name} : artefact RAW_TEXT "
370
  f"{text_artifact.id!r} sans URI.",
371
  )
372
  text_path = Path(text_artifact.uri)
373
  if not text_path.exists():
374
+ raise LLMAdapterError(
375
  f"{self.name} : fichier texte introuvable {text_path!r}.",
376
  )
377
 
 
394
 
395
  result = self.complete(prompt, image_b64=image_b64)
396
  if not result.success:
397
+ raise LLMAdapterError(
398
  f"{self.name} : LLM a échoué ({result.error}).",
399
  )
400
 
 
426
 
427
  __all__ = [
428
  "BaseLLMAdapter",
429
+ "LLMAdapterError",
430
  "LLMResult",
431
  "log_http_error",
432
  "normalize_llm_content",
picarones/adapters/ocr/base.py CHANGED
@@ -57,12 +57,17 @@ from abc import ABC, abstractmethod
57
  from typing import Any
58
 
59
  from picarones.domain.artifacts import Artifact, ArtifactType
60
- from picarones.domain.errors import PicaronesError
61
 
62
 
63
- class OCRAdapterError(PicaronesError):
64
  """Erreur typée pour un échec d'adapter OCR du nouveau monde.
65
 
 
 
 
 
 
66
  Le ``PipelineExecutor`` capture cette exception (et toute autre)
67
  et marque le step correspondant comme failed avec
68
  ``StepResult.error`` renseigné. Les callers downstream
 
57
  from typing import Any
58
 
59
  from picarones.domain.artifacts import Artifact, ArtifactType
60
+ from picarones.domain.errors import AdapterStepError
61
 
62
 
63
+ class OCRAdapterError(AdapterStepError):
64
  """Erreur typée pour un échec d'adapter OCR du nouveau monde.
65
 
66
+ Hérite de ``AdapterStepError`` (Sprint S52) qui hérite de
67
+ ``PicaronesError``. Un caller peut catcher
68
+ ``AdapterStepError`` pour toute erreur d'adapter (OCR/LLM/VLM)
69
+ sans connaître la sous-classe.
70
+
71
  Le ``PipelineExecutor`` capture cette exception (et toute autre)
72
  et marque le step correspondant comme failed avec
73
  ``StepResult.error`` renseigné. Les callers downstream
picarones/adapters/storage/job_store.py CHANGED
@@ -126,8 +126,17 @@ class JobRecord:
126
  return self.status in _LIVE_STATUSES
127
 
128
 
129
- class JobStoreError(Exception):
130
- """Erreur de persistance SQLite côté JobStore."""
 
 
 
 
 
 
 
 
 
131
 
132
 
133
  class JobStore:
 
126
  return self.status in _LIVE_STATUSES
127
 
128
 
129
+ from picarones.domain.errors import PicaronesError
130
+
131
+
132
+ class JobStoreError(PicaronesError):
133
+ """Erreur de persistance SQLite côté JobStore.
134
+
135
+ Sprint S52 : hérite désormais de ``PicaronesError`` (avant
136
+ héritait directement d'``Exception`` — un caller qui faisait
137
+ ``except PicaronesError`` ratait silencieusement les erreurs
138
+ JobStore).
139
+ """
140
 
141
 
142
  class JobStore:
picarones/adapters/vlm/base.py CHANGED
@@ -33,12 +33,20 @@ 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
 
@@ -84,18 +92,18 @@ class BaseVLMAdapter(BaseLLMAdapter):
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
 
@@ -109,7 +117,7 @@ class BaseVLMAdapter(BaseLLMAdapter):
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
 
@@ -134,4 +142,4 @@ class BaseVLMAdapter(BaseLLMAdapter):
134
  }
135
 
136
 
137
- __all__ = ["BaseVLMAdapter"]
 
33
  from typing import Any
34
 
35
  from picarones.adapters.llm.base import BaseLLMAdapter
 
36
  from picarones.domain.artifacts import Artifact, ArtifactType
37
+ from picarones.domain.errors import AdapterStepError
38
 
39
  logger = logging.getLogger(__name__)
40
 
41
 
42
+ class VLMAdapterError(AdapterStepError):
43
+ """Erreur typée pour un échec d'adapter VLM (Sprint S52).
44
+
45
+ Hérite de ``AdapterStepError`` (commune avec OCR et LLM).
46
+ Avant S52, les VLM levaient ``OCRAdapterError`` par confusion.
47
+ """
48
+
49
+
50
  class BaseVLMAdapter(BaseLLMAdapter):
51
  """Adapter VLM qui transcrit une IMAGE en RAW_TEXT.
52
 
 
92
  ``{RAW_TEXT: Artifact}``.
93
  """
94
  if ArtifactType.IMAGE not in inputs:
95
+ raise VLMAdapterError(
96
  f"{self.name} : input IMAGE manquant.",
97
  )
98
  image_artifact = inputs[ArtifactType.IMAGE]
99
  if image_artifact.uri is None:
100
+ raise VLMAdapterError(
101
  f"{self.name} : artefact image "
102
  f"{image_artifact.id!r} sans URI.",
103
  )
104
  image_path = Path(image_artifact.uri)
105
  if not image_path.exists():
106
+ raise VLMAdapterError(
107
  f"{self.name} : image introuvable {image_path!r}.",
108
  )
109
 
 
117
 
118
  result = self.complete(prompt, image_b64=image_b64)
119
  if not result.success:
120
+ raise VLMAdapterError(
121
  f"{self.name} : VLM a échoué ({result.error}).",
122
  )
123
 
 
142
  }
143
 
144
 
145
+ __all__ = ["BaseVLMAdapter", "VLMAdapterError"]
picarones/domain/errors.py CHANGED
@@ -57,9 +57,21 @@ class CorpusSpecError(PicaronesError):
57
  """
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
60
  __all__ = [
61
  "PicaronesError",
62
  "ArtifactValidationError",
63
  "ProjectionError",
64
  "CorpusSpecError",
 
65
  ]
 
57
  """
58
 
59
 
60
+ class AdapterStepError(PicaronesError):
61
+ """Racine commune des erreurs d'adapter (OCR / LLM / VLM) — Sprint S52.
62
+
63
+ Permet à un caller (typiquement le ``PipelineExecutor``) de
64
+ catcher *« toute erreur d'adapter »* sans avoir à connaître la
65
+ sous-classe spécifique. Les sous-classes ``OCRAdapterError``,
66
+ ``LLMAdapterError``, ``VLMAdapterError`` héritent toutes de
67
+ ``AdapterStepError``.
68
+ """
69
+
70
+
71
  __all__ = [
72
  "PicaronesError",
73
  "ArtifactValidationError",
74
  "ProjectionError",
75
  "CorpusSpecError",
76
+ "AdapterStepError",
77
  ]
tests/adapters/llm/test_sprint_a14_s44_llm_step_executor.py CHANGED
@@ -24,7 +24,7 @@ from pathlib import Path
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
 
@@ -184,7 +184,7 @@ class TestLLMExecuteNominal:
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={},
@@ -199,7 +199,7 @@ class TestLLMExecuteErrors:
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={},
@@ -208,7 +208,7 @@ class TestLLMExecuteErrors:
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",
@@ -223,7 +223,7 @@ class TestLLMExecuteErrors:
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={},
 
24
  import pytest
25
 
26
  from picarones.adapters.llm.base import BaseLLMAdapter
27
+ from picarones.adapters.llm.base import LLMAdapterError
28
  from picarones.domain.artifacts import Artifact, ArtifactType
29
  from picarones.pipeline.types import RunContext
30
 
 
184
  class TestLLMExecuteErrors:
185
  def test_missing_raw_text_raises(self) -> None:
186
  adapter = _StubLLMAdapter()
187
+ with pytest.raises(LLMAdapterError, match="RAW_TEXT manquant"):
188
  adapter.execute(
189
  inputs={},
190
  params={},
 
199
  type=ArtifactType.RAW_TEXT,
200
  uri=None,
201
  )
202
+ with pytest.raises(LLMAdapterError, match="sans URI"):
203
  adapter.execute(
204
  inputs={ArtifactType.RAW_TEXT: artifact},
205
  params={},
 
208
 
209
  def test_text_path_not_existing_raises(self) -> None:
210
  adapter = _StubLLMAdapter()
211
+ with pytest.raises(LLMAdapterError, match="introuvable"):
212
  adapter.execute(
213
  inputs={ArtifactType.RAW_TEXT: _make_text_artifact(
214
  "/nonexistent/x.txt",
 
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(LLMAdapterError, match="LLM a échoué"):
227
  adapter.execute(
228
  inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))},
229
  params={},
tests/adapters/vlm/test_sprint_a14_s45_vlm_adapters.py CHANGED
@@ -11,7 +11,7 @@ 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,
@@ -169,7 +169,7 @@ class TestVLMExecuteNominal:
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:
@@ -180,7 +180,7 @@ class TestVLMExecuteErrors:
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={},
@@ -189,7 +189,7 @@ class TestVLMExecuteErrors:
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",
@@ -202,7 +202,7 @@ class TestVLMExecuteErrors:
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={},
 
11
 
12
  import pytest
13
 
14
+ from picarones.adapters.vlm.base import VLMAdapterError
15
  from picarones.adapters.vlm import (
16
  AnthropicVLMAdapter,
17
  BaseVLMAdapter,
 
169
  class TestVLMExecuteErrors:
170
  def test_missing_image_raises(self) -> None:
171
  adapter = _StubVLMAdapter()
172
+ with pytest.raises(VLMAdapterError, match="IMAGE manquant"):
173
  adapter.execute(inputs={}, params={}, context=_make_context())
174
 
175
  def test_image_without_uri_raises(self) -> None:
 
180
  type=ArtifactType.IMAGE,
181
  uri=None,
182
  )
183
+ with pytest.raises(VLMAdapterError, match="sans URI"):
184
  adapter.execute(
185
  inputs={ArtifactType.IMAGE: artifact},
186
  params={},
 
189
 
190
  def test_image_path_not_existing_raises(self) -> None:
191
  adapter = _StubVLMAdapter()
192
+ with pytest.raises(VLMAdapterError, match="introuvable"):
193
  adapter.execute(
194
  inputs={ArtifactType.IMAGE: _make_image_artifact(
195
  "/nonexistent/img.png",
 
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(VLMAdapterError, match="VLM a échoué"):
206
  adapter.execute(
207
  inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))},
208
  params={},
tests/domain/test_sprint_a14_s52_error_hierarchy.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S52 — hiérarchie d'erreurs unifiée (fix audit #7 + #11).
2
+
3
+ Avant S52 :
4
+ - LLM/VLM levaient OCRAdapterError (mauvaise classe).
5
+ - JobStoreError héritait de Exception (pas de PicaronesError).
6
+ - Pas de racine commune AdapterStepError pour catcher OCR+LLM+VLM.
7
+
8
+ Après S52 :
9
+ - AdapterStepError(PicaronesError) est la racine commune.
10
+ - OCRAdapterError, LLMAdapterError, VLMAdapterError héritent.
11
+ - JobStoreError hérite de PicaronesError.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+ from picarones.adapters.llm.base import LLMAdapterError
19
+ from picarones.adapters.ocr.base import OCRAdapterError
20
+ from picarones.adapters.storage import JobStoreError
21
+ from picarones.adapters.vlm.base import VLMAdapterError
22
+ from picarones.domain.errors import AdapterStepError, PicaronesError
23
+
24
+
25
+ class TestErrorInheritance:
26
+ def test_ocr_inherits_adapter_step_error(self) -> None:
27
+ assert issubclass(OCRAdapterError, AdapterStepError)
28
+ assert issubclass(OCRAdapterError, PicaronesError)
29
+
30
+ def test_llm_inherits_adapter_step_error(self) -> None:
31
+ assert issubclass(LLMAdapterError, AdapterStepError)
32
+ assert issubclass(LLMAdapterError, PicaronesError)
33
+
34
+ def test_vlm_inherits_adapter_step_error(self) -> None:
35
+ assert issubclass(VLMAdapterError, AdapterStepError)
36
+ assert issubclass(VLMAdapterError, PicaronesError)
37
+
38
+ def test_jobstore_inherits_picarones_error(self) -> None:
39
+ # Avant S52, héritait de Exception → un caller `except
40
+ # PicaronesError` ratait JobStoreError. Maintenant inclus.
41
+ assert issubclass(JobStoreError, PicaronesError)
42
+
43
+
44
+ class TestPolymorphicCatch:
45
+ """Un caller peut catcher AdapterStepError pour gérer toute
46
+ erreur d'adapter sans connaître la sous-classe."""
47
+
48
+ def test_catches_ocr(self) -> None:
49
+ with pytest.raises(AdapterStepError):
50
+ raise OCRAdapterError("ocr boom")
51
+
52
+ def test_catches_llm(self) -> None:
53
+ with pytest.raises(AdapterStepError):
54
+ raise LLMAdapterError("llm boom")
55
+
56
+ def test_catches_vlm(self) -> None:
57
+ with pytest.raises(AdapterStepError):
58
+ raise VLMAdapterError("vlm boom")
59
+
60
+ def test_picarones_catches_all_adapter_errors(self) -> None:
61
+ for cls in (OCRAdapterError, LLMAdapterError, VLMAdapterError):
62
+ with pytest.raises(PicaronesError):
63
+ raise cls("boom")
64
+
65
+ def test_picarones_catches_jobstore(self) -> None:
66
+ with pytest.raises(PicaronesError):
67
+ raise JobStoreError("store boom")