File size: 12,276 Bytes
39b4865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6362212
39b4865
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""Tests pour le sprint 15 — Correction des bugs dans les pipelines OCR+LLM.

Bug 1 : Sortie LLM vide → WARNING logué + pas de crash
Bug 2 : CER 0.00% pour hypothèse vide → doit être 1.0 (100%)
Bug 3 : Divergence runner/rapport → cohérence des métriques
"""
from __future__ import annotations

import logging
from unittest.mock import MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# Bug 2 — compute_metrics : hypothèse vide
# ---------------------------------------------------------------------------

class TestEmptyHypothesisMetrics:
    """compute_metrics doit retourner CER=1.0, pas 0.0, pour hypothèse vide."""

    def test_empty_hypothesis_cer_is_one(self):
        from picarones.core.metrics import compute_metrics
        result = compute_metrics("Bonjour le monde", "")
        assert result.cer == pytest.approx(1.0)
        assert result.error is None

    def test_empty_hypothesis_all_metrics_are_one(self):
        from picarones.core.metrics import compute_metrics
        result = compute_metrics("hello world", "")
        assert result.cer == pytest.approx(1.0)
        assert result.wer == pytest.approx(1.0)
        assert result.mer == pytest.approx(1.0)
        assert result.wil == pytest.approx(1.0)

    def test_whitespace_only_hypothesis_cer_is_one(self):
        from picarones.core.metrics import compute_metrics
        result = compute_metrics("Bonjour", "   \t\n")
        assert result.cer == pytest.approx(1.0)

    def test_none_hypothesis_guarded(self):
        """compute_metrics ne doit pas planter si hypothesis=None."""
        from picarones.core.metrics import compute_metrics
        # None ne sera jamais passé en pratique, mais on teste la robustesse
        # via une chaîne vide (le runner convertit None → "")
        result = compute_metrics("test", "")
        assert result.cer == pytest.approx(1.0)

    def test_both_empty_cer_is_zero(self):
        """Référence ET hypothèse vides → CER=0.0 (pas d'erreur à mesurer)."""
        from picarones.core.metrics import compute_metrics
        result = compute_metrics("", "")
        assert result.cer == pytest.approx(0.0)

    def test_empty_reference_nonempty_hypothesis(self):
        """Référence vide avec hypothèse non vide → CER=1.0 (comportement existant)."""
        from picarones.core.metrics import compute_metrics
        result = compute_metrics("", "something")
        assert result.cer == pytest.approx(1.0)

    def test_normal_case_unchanged(self):
        """Un cas normal ne doit pas être affecté par le guard."""
        from picarones.core.metrics import compute_metrics
        result = compute_metrics("abcd", "abce")
        assert result.cer == pytest.approx(0.25)
        assert result.error is None


# ---------------------------------------------------------------------------
# Bug 1 — MistralAdapter : WARNING pour réponse vide
# ---------------------------------------------------------------------------

class TestMistralAdapterLogging:
    """MistralAdapter doit loguer un WARNING si la réponse LLM est vide."""

    def _make_mock_mistral_module(self, content: str | None):
        """Retourne un module mistralai simulé avec la réponse donnée."""
        mock_response = MagicMock()
        mock_response.choices = [MagicMock()]
        mock_response.choices[0].message.content = content

        mock_client = MagicMock()
        mock_client.chat.complete.return_value = mock_response

        MockMistralClass = MagicMock(return_value=mock_client)

        import types
        fake_module = types.ModuleType("mistralai")
        fake_module.Mistral = MockMistralClass
        return fake_module, mock_client

    def _run_adapter(self, adapter, fake_mod, prompt="test prompt", image_b64=None):
        """Exécute l'adapter avec le module mistralai simulé."""
        import sys
        with patch.dict(sys.modules, {"mistralai": fake_mod}):
            adapter._api_key = "fake-key"  # injecter la clé directement
            return adapter.complete(prompt, image_b64=image_b64)

    def test_warning_on_empty_response(self, caplog):
        """Un WARNING doit être émis si le LLM retourne une chaîne vide."""
        from picarones.llm.mistral_adapter import MistralAdapter

        fake_mod, _ = self._make_mock_mistral_module("")
        adapter = MistralAdapter(model="ministral-3b-latest")

        with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
            result = self._run_adapter(adapter, fake_mod)

        assert result.text == ""
        assert any(
            "vide" in rec.message.lower() or "empty" in rec.message.lower()
            for rec in caplog.records
            if rec.levelno >= logging.WARNING
        ), f"WARNING attendu, messages : {[r.message for r in caplog.records]}"

    def test_no_warning_on_normal_response(self, caplog):
        """Aucun WARNING ne doit être émis pour une réponse normale."""
        from picarones.llm.mistral_adapter import MistralAdapter

        fake_mod, _ = self._make_mock_mistral_module("Texte OCR corrigé")
        adapter = MistralAdapter(model="ministral-3b-latest")

        with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
            result = self._run_adapter(adapter, fake_mod)

        assert result.text == "Texte OCR corrigé"
        assert not any(rec.levelno >= logging.WARNING for rec in caplog.records)

    def test_warning_on_none_response_content(self, caplog):
        """WARNING doit être émis si message.content est None."""
        from picarones.llm.mistral_adapter import MistralAdapter

        fake_mod, _ = self._make_mock_mistral_module(None)
        adapter = MistralAdapter(model="ministral-3b-latest")

        with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
            result = self._run_adapter(adapter, fake_mod)

        assert result.text == ""
        assert any(rec.levelno >= logging.WARNING for rec in caplog.records)

    def test_text_only_models_set_exists(self):
        """La liste des modèles text-only doit contenir ministral-3b."""
        from picarones.llm.mistral_adapter import _TEXT_ONLY_MODELS
        assert "ministral-3b-latest" in _TEXT_ONLY_MODELS

    def test_image_ignored_for_text_only_model(self, caplog):
        """L'image doit être ignorée (avec WARNING) pour un modèle text-only."""
        from picarones.llm.mistral_adapter import MistralAdapter

        fake_mod, mock_client = self._make_mock_mistral_module("résultat")
        adapter = MistralAdapter(model="ministral-3b-latest")

        with caplog.at_level(logging.WARNING, logger="picarones.llm.mistral_adapter"):
            self._run_adapter(adapter, fake_mod, image_b64="fake_b64")

        # L'appel doit avoir été fait SANS image (modèle text-only)
        call_kwargs = mock_client.chat.complete.call_args
        _, kwargs = call_kwargs
        msg_content = kwargs.get("messages", [{}])[0].get("content", "")
        assert isinstance(msg_content, str), "Image aurait dû être ignorée (content doit être str)"
        # Au moins un WARNING doit mentionner l'image ignorée
        assert any("ignor" in rec.message.lower() for rec in caplog.records
                   if rec.levelno >= logging.WARNING)


# ---------------------------------------------------------------------------
# Bug 1 — OCRLLMPipeline : WARNING quand le LLM retourne texte vide
# ---------------------------------------------------------------------------

class TestPipelineEmptyLLMResponse:
    """Le pipeline doit loguer un WARNING si le LLM retourne un texte vide."""

    def _make_pipeline(self, llm_text: str):
        """Crée un pipeline dont le LLM retourne llm_text."""
        from picarones.pipelines.base import OCRLLMPipeline, PipelineMode
        from picarones.llm.base import LLMResult

        mock_ocr = MagicMock()
        mock_ocr.name = "mock_ocr"
        mock_ocr.run.return_value = MagicMock(text="texte ocr brut", error=None, success=True)
        mock_ocr._safe_version.return_value = "1.0"

        mock_llm = MagicMock()
        mock_llm.name = "mock_llm"
        mock_llm.model = "mock-model"
        mock_llm.complete.return_value = LLMResult(
            model_id="mock-model", text=llm_text, duration_seconds=0.1,
        )

        return OCRLLMPipeline(
            ocr_engine=mock_ocr,
            llm_adapter=mock_llm,
            mode=PipelineMode.TEXT_ONLY,
            prompt="correction_medieval_french.txt",
        )

    def test_warning_on_empty_llm_output(self, tmp_path, caplog):
        """WARNING doit être logu si le LLM retourne une chaîne vide."""
        # Créer une fausse image
        img_path = tmp_path / "test.png"
        img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

        pipeline = self._make_pipeline("")
        with caplog.at_level(logging.WARNING, logger="picarones.pipelines.base"):
            result = pipeline.run(img_path)

        assert result.text == ""
        assert any(
            "vide" in rec.message.lower() or "empty" in rec.message.lower()
            for rec in caplog.records
            if rec.levelno >= logging.WARNING
        ), f"WARNING attendu, messages : {[r.message for r in caplog.records]}"

    def test_no_warning_on_normal_llm_output(self, tmp_path, caplog):
        """Aucun WARNING ne doit être émis pour une sortie LLM normale."""
        img_path = tmp_path / "test.png"
        img_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)

        pipeline = self._make_pipeline("Texte corrigé par le LLM")
        with caplog.at_level(logging.WARNING, logger="picarones.pipelines.base"):
            result = pipeline.run(img_path)

        assert result.text == "Texte corrigé par le LLM"
        assert not any(
            "vide" in rec.message.lower()
            for rec in caplog.records
            if rec.levelno >= logging.WARNING
        )


# ---------------------------------------------------------------------------
# Bug 3 — Cohérence runner/rapport : empty hypothesis → CER 1.0 dans DocumentResult
# ---------------------------------------------------------------------------

class TestRunnerDocumentResultCohérence:
    """Le DocumentResult doit stocker CER=1.0 pour une hypothèse vide."""

    def test_empty_hypothesis_stored_as_cer_one(self):
        """_compute_document_result avec text="" → metrics.cer = 1.0."""
        from picarones.core.runner import _compute_document_result
        from picarones.engines.base import EngineResult

        ocr_result = EngineResult(
            engine_name="TestEngine",
            image_path="fake.png",
            text="",       # ← sortie vide
            duration_seconds=1.0,
            error=None,    # ← pas d'erreur technique
        )

        doc_result = _compute_document_result(
            doc_id="doc1",
            image_path="fake.png",
            ground_truth="Bonjour le monde",
            ocr_result=ocr_result,
            char_exclude=None,
        )

        assert doc_result.metrics.cer == pytest.approx(1.0), (
            f"CER attendu 1.0 pour hypothèse vide, obtenu {doc_result.metrics.cer}"
        )
        assert doc_result.metrics.error is None, (
            "L'erreur ne devrait pas être renseignée — c'est une hypothèse vide, pas une erreur technique"
        )

    def test_engine_error_also_gives_cer_one(self):
        """EngineResult avec error → metrics.cer = 1.0 (comportement existant)."""
        from picarones.core.runner import _compute_document_result
        from picarones.engines.base import EngineResult

        ocr_result = EngineResult(
            engine_name="TestEngine",
            image_path="fake.png",
            text="",
            duration_seconds=0.0,
            error="Moteur en erreur",
        )

        doc_result = _compute_document_result(
            doc_id="doc1",
            image_path="fake.png",
            ground_truth="Bonjour le monde",
            ocr_result=ocr_result,
            char_exclude=None,
        )

        assert doc_result.metrics.cer == pytest.approx(1.0)
        assert doc_result.metrics.error is not None