Picarones / tests /web /test_s9_prompt_loading.py
Claude
refactor(api): renommer PipelineConfig.ocr_engine → engine_name (Phase 5b)
bc96a54 unverified
Raw
History Blame
6.45 kB
"""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."
)
@pytest.mark.parametrize(
"filename",
[
"correction_medieval_french.txt",
"correction_medieval_english.txt",
"correction_early_modern_english.txt",
"correction_imprime_ancien.txt",
"correction_image_medieval_french.txt",
"zero_shot_medieval_french.txt",
"zero_shot_medieval_english.txt",
"zero_shot_imprime_ancien.txt",
],
)
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)