Spaces:
Sleeping
Sleeping
Claude
refactor(api): renommer PipelineConfig.ocr_engine → engine_name (Phase 5b)
bc96a54 unverified | """Sprint S9 — régression critique de la post-correction LLM. | |
| Bug observé en prod (interface web, 2026-05-11) : un benchmark | |
| avec post-correction LLM produisait, au lieu d'un texte OCR | |
| corrigé, un **méta-discours** du LLM décrivant le contenu | |
| hypothétique du fichier prompt : | |
| It looks like you're referring to a file named | |
| "correction_early_modern_english.txt"... | |
| Cause racine | |
| ------------ | |
| ``_engine_from_competitor`` (couche web) passait à | |
| ``OCRLLMPipelineConfig.prompt_template`` le **nom du fichier** | |
| (``"correction_early_modern_english.txt"``) au lieu de son | |
| **contenu**. Le pipeline canonique n'a pas de logique de | |
| chargement disque — il s'attend à recevoir une string brute. | |
| Avant le sprint H.2.c-d (mai 2026), l'``OCRLLMPipeline`` legacy | |
| lisait elle-même le fichier depuis ``picarones/prompts/``. Au | |
| moment de la migration, ce chargement n'a pas été reporté côté | |
| factory web. | |
| Symptôme côté LLM : le template substitué via | |
| ``_substitute_prompt_variables(template, text, image_b64)`` | |
| ne contenait ni ``{ocr_output}`` ni ``{image_b64}`` ni | |
| ``{text}`` — donc ``template.format(text=text)`` retournait la | |
| chaîne ``"correction_early_modern_english.txt"`` inchangée. | |
| Le LLM recevait ce filename comme prompt complet → réponse | |
| méta-discursive. | |
| Le fix charge le contenu du prompt côté ``_engine_from_competitor`` | |
| via le helper ``_load_prompt_content``. | |
| """ | |
| from __future__ import annotations | |
| import pytest | |
| from picarones.interfaces.web.benchmark_utils import ( | |
| _engine_from_competitor, | |
| _load_prompt_content, | |
| ) | |
| from picarones.interfaces.web.models import PipelineConfig | |
| class TestLoadPromptContent: | |
| """Le helper doit retourner le CONTENU du fichier prompt | |
| embarqué, pas le filename.""" | |
| def test_loads_real_content_not_filename(self) -> None: | |
| content = _load_prompt_content("correction_medieval_french.txt") | |
| assert content != "correction_medieval_french.txt" | |
| assert len(content) > 100, ( | |
| "Le prompt embarqué fait plusieurs centaines de chars — " | |
| "si on en lit < 100 c'est probablement un fichier vide ou " | |
| "le filename brut." | |
| ) | |
| def test_all_embedded_prompts_loadable(self, filename: str) -> None: | |
| """Tous les prompts livrés avec le package doivent être | |
| chargeables — garde-fou contre la suppression accidentelle | |
| d'un fichier référencé par défaut.""" | |
| content = _load_prompt_content(filename) | |
| assert content | |
| # Heuristique : un vrai prompt LLM contient soit | |
| # ``{ocr_output}`` (convention legacy) soit ``{text}`` | |
| # (convention rewrite) — pas le nom du fichier. | |
| assert ( | |
| "{ocr_output}" in content | |
| or "{text}" in content | |
| or "{image_b64}" in content | |
| ), ( | |
| f"Prompt {filename!r} n'a pas de placeholder variable — " | |
| "il ne pourra pas être substitué par le LLM, c'est un " | |
| "fichier inerte." | |
| ) | |
| def test_traversal_attempt_rejected(self) -> None: | |
| """Le loader refuse de remonter hors du dossier prompts — | |
| défense en profondeur contre un caller qui aurait court- | |
| circuité ``validated_prompt_filename``.""" | |
| with pytest.raises(ValueError, match="hors de la bibliothèque"): | |
| _load_prompt_content("../../../etc/passwd") | |
| def test_unknown_filename_raises_with_listing(self) -> None: | |
| """Filename inconnu → ``FileNotFoundError`` avec la liste | |
| des fichiers disponibles, utile pour debug ops.""" | |
| with pytest.raises(FileNotFoundError) as exc_info: | |
| _load_prompt_content("never_existed.txt") | |
| msg = str(exc_info.value) | |
| # La liste des fichiers réels doit apparaître pour guider | |
| # l'utilisateur. | |
| assert "correction_medieval_french.txt" in msg | |
| class TestEngineFromCompetitorPassesPromptContent: | |
| """Régression : ``_engine_from_competitor`` doit injecter le | |
| CONTENU du prompt dans ``OCRLLMPipelineConfig.prompt_template``, | |
| pas le filename brut.""" | |
| def test_pipeline_template_contains_file_content(self) -> None: | |
| comp = PipelineConfig( | |
| name="t", | |
| engine_name="tesseract", | |
| ocr_model="fra", | |
| llm_provider="mistral", | |
| llm_model="mistral-small-latest", | |
| pipeline_mode="text_only", | |
| prompt_file="correction_early_modern_english.txt", | |
| ) | |
| pipeline = _engine_from_competitor(comp) | |
| # Le contenu réel commence par "You are an expert" (vérifié | |
| # dans le fichier embarqué). | |
| assert pipeline.prompt_template != "correction_early_modern_english.txt" | |
| assert len(pipeline.prompt_template) > 100 | |
| assert "Early Modern English" in pipeline.prompt_template | |
| def test_default_prompt_loaded_when_none_specified(self) -> None: | |
| """``prompt_file`` vide → default | |
| ``correction_medieval_french.txt`` chargé.""" | |
| comp = PipelineConfig( | |
| engine_name="tesseract", ocr_model="fra", | |
| llm_provider="mistral", llm_model="m", | |
| pipeline_mode="text_only", prompt_file="", | |
| ) | |
| pipeline = _engine_from_competitor(comp) | |
| assert pipeline.prompt_template != "correction_medieval_french.txt" | |
| assert "{ocr_output}" in pipeline.prompt_template or "{text}" in pipeline.prompt_template | |
| def test_unknown_prompt_file_raises(self) -> None: | |
| """Si le frontend envoie un filename qui n'existe pas, le | |
| factory doit lever proprement (pas continuer avec le filename | |
| comme prompt — c'est le bug d'origine).""" | |
| comp = PipelineConfig( | |
| engine_name="tesseract", ocr_model="fra", | |
| llm_provider="mistral", llm_model="m", | |
| pipeline_mode="text_only", | |
| prompt_file="prompt_que_personne_na_jamais_cree.txt", | |
| ) | |
| with pytest.raises(FileNotFoundError): | |
| _engine_from_competitor(comp) | |