Spaces:
Sleeping
Sleeping
Claude
fix: résoudre les 64 erreurs ruff pré-existantes révélées par le lint actif
6362212 unverified | """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 | |