Claude commited on
Commit
39b4865
·
unverified ·
1 Parent(s): 07b7d9b

fix(pipeline/metrics): 3 bugs pipelines OCR+LLM

Browse files

Bug 2 — CER 0.00% pour hypothèse vide (root cause)
metrics.py : ajout d'une garde explicite avant les appels jiwer.
Si l'hypothèse est vide (ou uniquement des espaces) avec une référence
non vide, retourne immédiatement CER=WER=MER=WIL=1.0.
jiwer.wer("ref", "") lève ZeroDivisionError ; l'ancien except retournait
silencieusement cer=0.0 au lieu de 1.0.

Bug 1 — Sortie LLM vide sans avertissement
mistral_adapter.py :
- WARNING logu si response.choices[0].message.content est vide ou None
- DEBUG logs : longueur prompt, longueur réponse, extrait réponse
- Constante _TEXT_ONLY_MODELS pour ministral-3b/8b-latest et équivalents
- WARNING + neutralisation de l'image si modèle text-only reçoit image_b64
pipelines/base.py :
- WARNING si le LLM retourne un texte vide (avec conseils de diagnostic)
- WARNING si le moteur OCR produit un texte vide avant l'envoi au LLM
- DEBUG logs : longueur texte OCR, longueur réponse LLM, extrait

Bug 3 — Divergence runner/rapport
Résolu par le fix Bug 2 : le DocumentResult stocke désormais CER=1.0
(via compute_metrics) pour toute hypothèse vide, garantissant la
cohérence entre le log runner et l'affichage dans le rapport HTML.

Tests : +20 tests (test_metrics.py +4, test_sprint15_llm_pipeline_bugs.py +16)
890 tests passent.

https://claude.ai/code/session_017gXea9mxBQqDTAsSQd7aAq

picarones/core/metrics.py CHANGED
@@ -152,6 +152,18 @@ def compute_metrics(
152
  error="jiwer n'est pas installé (pip install jiwer)",
153
  )
154
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  try:
156
  # Exclusion de caractères avant tout calcul
157
  if char_exclude:
 
152
  error="jiwer n'est pas installé (pip install jiwer)",
153
  )
154
 
155
+ # Hypothèse vide avec référence non vide = erreur totale (toutes les
156
+ # métriques jiwer lèvent une ZeroDivisionError sur hypothèse vide).
157
+ ref_stripped = reference.strip()
158
+ hyp_stripped = hypothesis.strip() if hypothesis else ""
159
+ if ref_stripped and not hyp_stripped:
160
+ return MetricsResult(
161
+ cer=1.0, cer_nfc=1.0, cer_caseless=1.0,
162
+ wer=1.0, wer_normalized=1.0, mer=1.0, wil=1.0,
163
+ reference_length=len(reference),
164
+ hypothesis_length=0,
165
+ )
166
+
167
  try:
168
  # Exclusion de caractères avant tout calcul
169
  if char_exclude:
picarones/llm/mistral_adapter.py CHANGED
@@ -2,11 +2,25 @@
2
 
3
  from __future__ import annotations
4
 
 
5
  import os
6
  from typing import Optional
7
 
8
  from picarones.llm.base import BaseLLMAdapter
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  class MistralAdapter(BaseLLMAdapter):
12
  """Adaptateur pour les modèles Mistral AI.
@@ -15,6 +29,11 @@ class MistralAdapter(BaseLLMAdapter):
15
 
16
  Modes supportés : text_only (tous modèles), text_and_image et zero_shot
17
  avec les modèles multimodaux (pixtral-12b, pixtral-large).
 
 
 
 
 
18
  """
19
 
20
  @property
@@ -32,6 +51,11 @@ class MistralAdapter(BaseLLMAdapter):
32
  ) -> None:
33
  super().__init__(model, config)
34
  self._api_key = os.environ.get("MISTRAL_API_KEY")
 
 
 
 
 
35
 
36
  def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
37
  if not self._api_key:
@@ -49,6 +73,15 @@ class MistralAdapter(BaseLLMAdapter):
49
  temperature = float(self.config.get("temperature", 0.0))
50
  max_tokens = int(self.config.get("max_tokens", 4096))
51
 
 
 
 
 
 
 
 
 
 
52
  if image_b64:
53
  content: list | str = [
54
  {"type": "text", "text": prompt},
@@ -60,10 +93,32 @@ class MistralAdapter(BaseLLMAdapter):
60
  else:
61
  content = prompt
62
 
 
 
 
 
 
63
  response = client.chat.complete(
64
  model=self.model,
65
  messages=[{"role": "user", "content": content}],
66
  temperature=temperature,
67
  max_tokens=max_tokens,
68
  )
69
- return response.choices[0].message.content or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
5
+ import logging
6
  import os
7
  from typing import Optional
8
 
9
  from picarones.llm.base import BaseLLMAdapter
10
 
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Modèles Mistral qui NE supportent PAS l'API chat/completions multimodale.
14
+ # Ces petits modèles sont text-only; le passer avec une image provoque une erreur.
15
+ _TEXT_ONLY_MODELS = frozenset({
16
+ "ministral-3b-latest",
17
+ "ministral-8b-latest",
18
+ "mistral-tiny",
19
+ "mistral-tiny-latest",
20
+ "open-mistral-7b",
21
+ "open-mixtral-8x7b",
22
+ })
23
+
24
 
25
  class MistralAdapter(BaseLLMAdapter):
26
  """Adaptateur pour les modèles Mistral AI.
 
29
 
30
  Modes supportés : text_only (tous modèles), text_and_image et zero_shot
31
  avec les modèles multimodaux (pixtral-12b, pixtral-large).
32
+
33
+ Note
34
+ ----
35
+ Les modèles ``ministral-3b-latest`` et ``ministral-8b-latest`` ne supportent
36
+ pas le mode multimodal — utiliser ``PipelineMode.TEXT_ONLY`` avec ces modèles.
37
  """
38
 
39
  @property
 
51
  ) -> None:
52
  super().__init__(model, config)
53
  self._api_key = os.environ.get("MISTRAL_API_KEY")
54
+ if self.model in _TEXT_ONLY_MODELS:
55
+ logger.info(
56
+ "[MistralAdapter] modèle '%s' : text-only (pas de support multimodal).",
57
+ self.model,
58
+ )
59
 
60
  def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
61
  if not self._api_key:
 
73
  temperature = float(self.config.get("temperature", 0.0))
74
  max_tokens = int(self.config.get("max_tokens", 4096))
75
 
76
+ # Les modèles text-only ne supportent pas les images
77
+ if image_b64 and self.model in _TEXT_ONLY_MODELS:
78
+ logger.warning(
79
+ "[MistralAdapter] modèle '%s' ne supporte pas les images — "
80
+ "image ignorée, appel en mode texte seul.",
81
+ self.model,
82
+ )
83
+ image_b64 = None
84
+
85
  if image_b64:
86
  content: list | str = [
87
  {"type": "text", "text": prompt},
 
93
  else:
94
  content = prompt
95
 
96
+ logger.debug(
97
+ "[MistralAdapter] appel %s — longueur prompt : %d caractères, image : %s",
98
+ self.model, len(prompt), "oui" if image_b64 else "non",
99
+ )
100
+
101
  response = client.chat.complete(
102
  model=self.model,
103
  messages=[{"role": "user", "content": content}],
104
  temperature=temperature,
105
  max_tokens=max_tokens,
106
  )
107
+ raw = response.choices[0].message.content
108
+ text = raw or ""
109
+
110
+ if not text or not text.strip():
111
+ logger.warning(
112
+ "[MistralAdapter] réponse vide reçue du modèle '%s' "
113
+ "(longueur brute : %s). "
114
+ "Vérifier que le modèle supporte l'API chat/completions et "
115
+ "que le prompt contient bien {ocr_output}.",
116
+ self.model, len(raw) if raw is not None else "None",
117
+ )
118
+ else:
119
+ logger.debug(
120
+ "[MistralAdapter] réponse reçue — %d caractères, extrait : %r",
121
+ len(text), text[:120],
122
+ )
123
+
124
+ return text
picarones/pipelines/base.py CHANGED
@@ -15,6 +15,7 @@ exposées via ``EngineResult.metadata``.
15
  from __future__ import annotations
16
 
17
  import base64
 
18
  import time
19
  from enum import Enum
20
  from pathlib import Path
@@ -23,6 +24,8 @@ from typing import Optional
23
  from picarones.engines.base import BaseOCREngine, EngineResult
24
  from picarones.llm.base import BaseLLMAdapter
25
 
 
 
26
 
27
  class PipelineMode(str, Enum):
28
  """Mode d'appel LLM dans le pipeline."""
@@ -146,6 +149,9 @@ class OCRLLMPipeline(BaseOCREngine):
146
  if self.mode == PipelineMode.ZERO_SHOT:
147
  image_b64 = _image_to_b64(image_path)
148
  prompt = self._build_prompt(image_b64=image_b64)
 
 
 
149
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
150
 
151
  elif self.mode == PipelineMode.TEXT_ONLY:
@@ -154,6 +160,16 @@ class OCRLLMPipeline(BaseOCREngine):
154
  ocr_result = self.ocr_engine.run(image_path)
155
  ocr_text = ocr_result.text
156
  self._last_ocr_text = ocr_text
 
 
 
 
 
 
 
 
 
 
157
  prompt = self._build_prompt(ocr_text=ocr_text)
158
  result = self.llm_adapter.complete(prompt)
159
 
@@ -163,6 +179,16 @@ class OCRLLMPipeline(BaseOCREngine):
163
  ocr_result = self.ocr_engine.run(image_path)
164
  ocr_text = ocr_result.text
165
  self._last_ocr_text = ocr_text
 
 
 
 
 
 
 
 
 
 
166
  image_b64 = _image_to_b64(image_path)
167
  prompt = self._build_prompt(ocr_text=ocr_text, image_b64=image_b64)
168
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
@@ -170,7 +196,23 @@ class OCRLLMPipeline(BaseOCREngine):
170
  if not result.success:
171
  raise RuntimeError(f"Erreur LLM ({self.llm_adapter.model}): {result.error}")
172
 
173
- return result.text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  # ------------------------------------------------------------------
176
  # Override run() pour injecter les métadonnées pipeline
 
15
  from __future__ import annotations
16
 
17
  import base64
18
+ import logging
19
  import time
20
  from enum import Enum
21
  from pathlib import Path
 
24
  from picarones.engines.base import BaseOCREngine, EngineResult
25
  from picarones.llm.base import BaseLLMAdapter
26
 
27
+ logger = logging.getLogger(__name__)
28
+
29
 
30
  class PipelineMode(str, Enum):
31
  """Mode d'appel LLM dans le pipeline."""
 
149
  if self.mode == PipelineMode.ZERO_SHOT:
150
  image_b64 = _image_to_b64(image_path)
151
  prompt = self._build_prompt(image_b64=image_b64)
152
+ logger.debug(
153
+ "[%s] zero-shot — longueur prompt : %d car.", self._name, len(prompt)
154
+ )
155
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
156
 
157
  elif self.mode == PipelineMode.TEXT_ONLY:
 
160
  ocr_result = self.ocr_engine.run(image_path)
161
  ocr_text = ocr_result.text
162
  self._last_ocr_text = ocr_text
163
+ logger.debug(
164
+ "[%s] texte OCR : %d car. → envoi au LLM.",
165
+ self._name, len(ocr_text),
166
+ )
167
+ if not ocr_text.strip():
168
+ logger.warning(
169
+ "[%s] le moteur OCR a produit un texte vide pour '%s'. "
170
+ "Le LLM recevra un prompt sans texte OCR ({ocr_output} vide).",
171
+ self._name, image_path.name,
172
+ )
173
  prompt = self._build_prompt(ocr_text=ocr_text)
174
  result = self.llm_adapter.complete(prompt)
175
 
 
179
  ocr_result = self.ocr_engine.run(image_path)
180
  ocr_text = ocr_result.text
181
  self._last_ocr_text = ocr_text
182
+ logger.debug(
183
+ "[%s] texte OCR : %d car. + image → envoi au LLM.",
184
+ self._name, len(ocr_text),
185
+ )
186
+ if not ocr_text.strip():
187
+ logger.warning(
188
+ "[%s] le moteur OCR a produit un texte vide pour '%s'. "
189
+ "Le LLM recevra un prompt sans texte OCR ({ocr_output} vide).",
190
+ self._name, image_path.name,
191
+ )
192
  image_b64 = _image_to_b64(image_path)
193
  prompt = self._build_prompt(ocr_text=ocr_text, image_b64=image_b64)
194
  result = self.llm_adapter.complete(prompt, image_b64=image_b64)
 
196
  if not result.success:
197
  raise RuntimeError(f"Erreur LLM ({self.llm_adapter.model}): {result.error}")
198
 
199
+ llm_text = result.text
200
+ if not llm_text or not llm_text.strip():
201
+ logger.warning(
202
+ "[%s] le LLM ('%s') a retourné un texte vide pour '%s'. "
203
+ "CER sera calculé à 1.0 (100%%). "
204
+ "Vérifier : (1) le prompt contient-il {ocr_output} ? "
205
+ "(2) le modèle supporte-t-il ce mode d'appel ? "
206
+ "(3) la réponse n'est-elle pas tronquée (max_tokens) ?",
207
+ self._name, self.llm_adapter.model, image_path.name,
208
+ )
209
+ else:
210
+ logger.debug(
211
+ "[%s] réponse LLM : %d car., extrait : %r",
212
+ self._name, len(llm_text), llm_text[:120],
213
+ )
214
+
215
+ return llm_text
216
 
217
  # ------------------------------------------------------------------
218
  # Override run() pour injecter les métadonnées pipeline
tests/test_metrics.py CHANGED
@@ -74,6 +74,34 @@ class TestComputeMetrics:
74
  result = compute_metrics("abcd", "abce")
75
  assert result.cer_percent == pytest.approx(25.0, rel=1e-2)
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
  class TestAggregateMetrics:
79
  """Tests de aggregate_metrics."""
 
74
  result = compute_metrics("abcd", "abce")
75
  assert result.cer_percent == pytest.approx(25.0, rel=1e-2)
76
 
77
+ # ── Bug fix : hypothèse vide → CER doit être 1.0, pas 0.0 (bug sprint 13) ──
78
+
79
+ def test_empty_hypothesis_cer_is_one(self):
80
+ """Hypothèse vide avec référence non vide doit donner CER=1.0."""
81
+ result = compute_metrics("Bonjour le monde", "")
82
+ assert result.cer == pytest.approx(1.0), (
83
+ f"CER attendu 1.0 pour hypothèse vide, obtenu {result.cer}"
84
+ )
85
+ assert result.error is None
86
+
87
+ def test_empty_hypothesis_wer_is_one(self):
88
+ """WER doit être 1.0 pour hypothèse vide (pas de ZeroDivisionError)."""
89
+ result = compute_metrics("hello world", "")
90
+ assert result.wer == pytest.approx(1.0)
91
+ assert result.mer == pytest.approx(1.0)
92
+ assert result.wil == pytest.approx(1.0)
93
+ assert result.error is None
94
+
95
+ def test_empty_hypothesis_whitespace_is_treated_as_empty(self):
96
+ """Hypothèse avec uniquement des espaces est traitée comme vide."""
97
+ result = compute_metrics("Bonjour", " ")
98
+ assert result.cer == pytest.approx(1.0)
99
+
100
+ def test_empty_hypothesis_hypothesis_length_is_zero(self):
101
+ """hypothesis_length doit être 0 pour hypothèse vide."""
102
+ result = compute_metrics("Bonjour le monde", "")
103
+ assert result.hypothesis_length == 0
104
+
105
 
106
  class TestAggregateMetrics:
107
  """Tests de aggregate_metrics."""
tests/test_sprint15_llm_pipeline_bugs.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests pour le sprint 15 — Correction des bugs dans les pipelines OCR+LLM.
2
+
3
+ Bug 1 : Sortie LLM vide → WARNING logué + pas de crash
4
+ Bug 2 : CER 0.00% pour hypothèse vide → doit être 1.0 (100%)
5
+ Bug 3 : Divergence runner/rapport → cohérence des métriques
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from pathlib import Path
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ import pytest
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Bug 2 — compute_metrics : hypothèse vide
18
+ # ---------------------------------------------------------------------------
19
+
20
+ class TestEmptyHypothesisMetrics:
21
+ """compute_metrics doit retourner CER=1.0, pas 0.0, pour hypothèse vide."""
22
+
23
+ def test_empty_hypothesis_cer_is_one(self):
24
+ from picarones.core.metrics import compute_metrics
25
+ result = compute_metrics("Bonjour le monde", "")
26
+ assert result.cer == pytest.approx(1.0)
27
+ assert result.error is None
28
+
29
+ def test_empty_hypothesis_all_metrics_are_one(self):
30
+ from picarones.core.metrics import compute_metrics
31
+ result = compute_metrics("hello world", "")
32
+ assert result.cer == pytest.approx(1.0)
33
+ assert result.wer == pytest.approx(1.0)
34
+ assert result.mer == pytest.approx(1.0)
35
+ assert result.wil == pytest.approx(1.0)
36
+
37
+ def test_whitespace_only_hypothesis_cer_is_one(self):
38
+ from picarones.core.metrics import compute_metrics
39
+ result = compute_metrics("Bonjour", " \t\n")
40
+ assert result.cer == pytest.approx(1.0)
41
+
42
+ def test_none_hypothesis_guarded(self):
43
+ """compute_metrics ne doit pas planter si hypothesis=None."""
44
+ from picarones.core.metrics import compute_metrics
45
+ # None ne sera jamais passé en pratique, mais on teste la robustesse
46
+ # via une chaîne vide (le runner convertit None → "")
47
+ result = compute_metrics("test", "")
48
+ assert result.cer == pytest.approx(1.0)
49
+
50
+ def test_both_empty_cer_is_zero(self):
51
+ """Référence ET hypothèse vides → CER=0.0 (pas d'erreur à mesurer)."""
52
+ from picarones.core.metrics import compute_metrics
53
+ result = compute_metrics("", "")
54
+ assert result.cer == pytest.approx(0.0)
55
+
56
+ def test_empty_reference_nonempty_hypothesis(self):
57
+ """Référence vide avec hypothèse non vide → CER=1.0 (comportement existant)."""
58
+ from picarones.core.metrics import compute_metrics
59
+ result = compute_metrics("", "something")
60
+ assert result.cer == pytest.approx(1.0)
61
+
62
+ def test_normal_case_unchanged(self):
63
+ """Un cas normal ne doit pas être affecté par le guard."""
64
+ from picarones.core.metrics import compute_metrics
65
+ result = compute_metrics("abcd", "abce")
66
+ assert result.cer == pytest.approx(0.25)
67
+ assert result.error is None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Bug 1 — MistralAdapter : WARNING pour réponse vide
72
+ # ---------------------------------------------------------------------------
73
+
74
+ class TestMistralAdapterLogging:
75
+ """MistralAdapter doit loguer un WARNING si la réponse LLM est vide."""
76
+
77
+ def _make_mock_mistral_module(self, content: str | None):
78
+ """Retourne un module mistralai simulé avec la réponse donnée."""
79
+ mock_response = MagicMock()
80
+ mock_response.choices = [MagicMock()]
81
+ mock_response.choices[0].message.content = content
82
+
83
+ mock_client = MagicMock()
84
+ mock_client.chat.complete.return_value = mock_response
85
+
86
+ MockMistralClass = MagicMock(return_value=mock_client)
87
+
88
+ import types
89
+ fake_module = types.ModuleType("mistralai")
90
+ fake_module.Mistral = MockMistralClass
91
+ return fake_module, mock_client
92
+
93
+ def _run_adapter(self, adapter, fake_mod, prompt="test prompt", image_b64=None):
94
+ """Exécute l'adapter avec le module mistralai simulé."""
95
+ import sys
96
+ with patch.dict(sys.modules, {"mistralai": fake_mod}):
97
+ adapter._api_key = "fake-key" # injecter la clé directement
98
+ return adapter.complete(prompt, image_b64=image_b64)
99
+
100
+ def test_warning_on_empty_response(self, caplog):
101
+ """Un WARNING doit être émis si le LLM retourne une chaîne vide."""
102
+ from picarones.llm.mistral_adapter import MistralAdapter
103
+
104
+ fake_mod, _ = self._make_mock_mistral_module("")
105
+ adapter = MistralAdapter(model="ministral-3b-latest")
106
+
107
+ with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
108
+ result = self._run_adapter(adapter, fake_mod)
109
+
110
+ assert result.text == ""
111
+ assert any(
112
+ "vide" in rec.message.lower() or "empty" in rec.message.lower()
113
+ for rec in caplog.records
114
+ if rec.levelno >= logging.WARNING
115
+ ), f"WARNING attendu, messages : {[r.message for r in caplog.records]}"
116
+
117
+ def test_no_warning_on_normal_response(self, caplog):
118
+ """Aucun WARNING ne doit être émis pour une réponse normale."""
119
+ from picarones.llm.mistral_adapter import MistralAdapter
120
+
121
+ fake_mod, _ = self._make_mock_mistral_module("Texte OCR corrigé")
122
+ adapter = MistralAdapter(model="ministral-3b-latest")
123
+
124
+ with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
125
+ result = self._run_adapter(adapter, fake_mod)
126
+
127
+ assert result.text == "Texte OCR corrigé"
128
+ assert not any(rec.levelno >= logging.WARNING for rec in caplog.records)
129
+
130
+ def test_warning_on_none_response_content(self, caplog):
131
+ """WARNING doit être émis si message.content est None."""
132
+ from picarones.llm.mistral_adapter import MistralAdapter
133
+
134
+ fake_mod, _ = self._make_mock_mistral_module(None)
135
+ adapter = MistralAdapter(model="ministral-3b-latest")
136
+
137
+ with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
138
+ result = self._run_adapter(adapter, fake_mod)
139
+
140
+ assert result.text == ""
141
+ assert any(rec.levelno >= logging.WARNING for rec in caplog.records)
142
+
143
+ def test_text_only_models_set_exists(self):
144
+ """La liste des modèles text-only doit contenir ministral-3b."""
145
+ from picarones.llm.mistral_adapter import _TEXT_ONLY_MODELS
146
+ assert "ministral-3b-latest" in _TEXT_ONLY_MODELS
147
+
148
+ def test_image_ignored_for_text_only_model(self, caplog):
149
+ """L'image doit être ignorée (avec WARNING) pour un modèle text-only."""
150
+ from picarones.llm.mistral_adapter import MistralAdapter
151
+
152
+ fake_mod, mock_client = self._make_mock_mistral_module("résultat")
153
+ adapter = MistralAdapter(model="ministral-3b-latest")
154
+
155
+ with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
156
+ result = self._run_adapter(adapter, fake_mod, image_b64="fake_b64")
157
+
158
+ # L'appel doit avoir été fait SANS image (modèle text-only)
159
+ call_kwargs = mock_client.chat.complete.call_args
160
+ _, kwargs = call_kwargs
161
+ msg_content = kwargs.get("messages", [{}])[0].get("content", "")
162
+ assert isinstance(msg_content, str), "Image aurait dû être ignorée (content doit être str)"
163
+ # Au moins un WARNING doit mentionner l'image ignorée
164
+ assert any("ignor" in rec.message.lower() for rec in caplog.records
165
+ if rec.levelno >= logging.WARNING)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Bug 1 — OCRLLMPipeline : WARNING quand le LLM retourne texte vide
170
+ # ---------------------------------------------------------------------------
171
+
172
+ class TestPipelineEmptyLLMResponse:
173
+ """Le pipeline doit loguer un WARNING si le LLM retourne un texte vide."""
174
+
175
+ def _make_pipeline(self, llm_text: str):
176
+ """Crée un pipeline dont le LLM retourne llm_text."""
177
+ from picarones.pipelines.base import OCRLLMPipeline, PipelineMode
178
+ from picarones.llm.base import LLMResult
179
+
180
+ mock_ocr = MagicMock()
181
+ mock_ocr.name = "mock_ocr"
182
+ mock_ocr.run.return_value = MagicMock(text="texte ocr brut", error=None, success=True)
183
+ mock_ocr._safe_version.return_value = "1.0"
184
+
185
+ mock_llm = MagicMock()
186
+ mock_llm.name = "mock_llm"
187
+ mock_llm.model = "mock-model"
188
+ mock_llm.complete.return_value = LLMResult(
189
+ model_id="mock-model", text=llm_text, duration_seconds=0.1,
190
+ )
191
+
192
+ return OCRLLMPipeline(
193
+ ocr_engine=mock_ocr,
194
+ llm_adapter=mock_llm,
195
+ mode=PipelineMode.TEXT_ONLY,
196
+ prompt="correction_medieval_french.txt",
197
+ )
198
+
199
+ def test_warning_on_empty_llm_output(self, tmp_path, caplog):
200
+ """WARNING doit être logu si le LLM retourne une chaîne vide."""
201
+ import shutil
202
+ # Créer une fausse image
203
+ img_path = tmp_path / "test.png"
204
+ img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
205
+
206
+ pipeline = self._make_pipeline("")
207
+ with caplog.at_level(logging.WARNING, logger="picarones.pipelines.base"):
208
+ result = pipeline.run(img_path)
209
+
210
+ assert result.text == ""
211
+ assert any(
212
+ "vide" in rec.message.lower() or "empty" in rec.message.lower()
213
+ for rec in caplog.records
214
+ if rec.levelno >= logging.WARNING
215
+ ), f"WARNING attendu, messages : {[r.message for r in caplog.records]}"
216
+
217
+ def test_no_warning_on_normal_llm_output(self, tmp_path, caplog):
218
+ """Aucun WARNING ne doit être émis pour une sortie LLM normale."""
219
+ img_path = tmp_path / "test.png"
220
+ img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
221
+
222
+ pipeline = self._make_pipeline("Texte corrigé par le LLM")
223
+ with caplog.at_level(logging.WARNING, logger="picarones.pipelines.base"):
224
+ result = pipeline.run(img_path)
225
+
226
+ assert result.text == "Texte corrigé par le LLM"
227
+ assert not any(
228
+ "vide" in rec.message.lower()
229
+ for rec in caplog.records
230
+ if rec.levelno >= logging.WARNING
231
+ )
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Bug 3 — Cohérence runner/rapport : empty hypothesis → CER 1.0 dans DocumentResult
236
+ # ---------------------------------------------------------------------------
237
+
238
+ class TestRunnerDocumentResultCohérence:
239
+ """Le DocumentResult doit stocker CER=1.0 pour une hypothèse vide."""
240
+
241
+ def test_empty_hypothesis_stored_as_cer_one(self):
242
+ """_compute_document_result avec text="" → metrics.cer = 1.0."""
243
+ from picarones.core.runner import _compute_document_result
244
+ from picarones.engines.base import EngineResult
245
+
246
+ ocr_result = EngineResult(
247
+ engine_name="TestEngine",
248
+ image_path="fake.png",
249
+ text="", # ← sortie vide
250
+ duration_seconds=1.0,
251
+ error=None, # ← pas d'erreur technique
252
+ )
253
+
254
+ doc_result = _compute_document_result(
255
+ doc_id="doc1",
256
+ image_path="fake.png",
257
+ ground_truth="Bonjour le monde",
258
+ ocr_result=ocr_result,
259
+ char_exclude=None,
260
+ )
261
+
262
+ assert doc_result.metrics.cer == pytest.approx(1.0), (
263
+ f"CER attendu 1.0 pour hypothèse vide, obtenu {doc_result.metrics.cer}"
264
+ )
265
+ assert doc_result.metrics.error is None, (
266
+ "L'erreur ne devrait pas être renseignée — c'est une hypothèse vide, pas une erreur technique"
267
+ )
268
+
269
+ def test_engine_error_also_gives_cer_one(self):
270
+ """EngineResult avec error → metrics.cer = 1.0 (comportement existant)."""
271
+ from picarones.core.runner import _compute_document_result
272
+ from picarones.engines.base import EngineResult
273
+
274
+ ocr_result = EngineResult(
275
+ engine_name="TestEngine",
276
+ image_path="fake.png",
277
+ text="",
278
+ duration_seconds=0.0,
279
+ error="Moteur en erreur",
280
+ )
281
+
282
+ doc_result = _compute_document_result(
283
+ doc_id="doc1",
284
+ image_path="fake.png",
285
+ ground_truth="Bonjour le monde",
286
+ ocr_result=ocr_result,
287
+ char_exclude=None,
288
+ )
289
+
290
+ assert doc_result.metrics.cer == pytest.approx(1.0)
291
+ assert doc_result.metrics.error is not None