Spaces:
Running
Running
File size: 7,249 Bytes
dbe59ee 5e48c0b dbe59ee 20af117 dbe59ee 7240e91 dbe59ee 7240e91 dbe59ee 7240e91 dbe59ee | 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 | """Sprint S9 — régression pour le bug du resolver d'adapter qui
plantait quand un même OCR apparaît à la fois en standalone et
encapsulé dans un pipeline OCR+LLM.
Bug observé en prod (interface web, 2026-05-10) :
Démarrage du benchmark…
6 documents chargés.
Concurrent : tesseract
Concurrent : tesseract:fra → mistral-small-latest
Erreur : Adapter resolver : nom 'tesseract' enregistré deux fois
avec des instances différentes — collision impossible à résoudre.
Cause : ``_engine_from_competitor`` crée une instance ``TesseractAdapter``
fraîche pour chaque ``PipelineConfig``. Quand deux concurrents
partagent le même moteur OCR (l'un seul, l'autre dans un pipeline),
``build_adapter_resolver`` voyait deux instances Python distinctes
sous le même ``name="tesseract"`` et levait ``PicaronesError`` à tort
— les deux instances étant fonctionnellement équivalentes (même
config Tesseract, sans état applicatif).
Fix : le resolver accepte désormais la 2e registration si l'état
public (``__dict__``) est identique. Configuration vraiment
différente → toujours rejet (le contrat de disambiguation est
préservé).
"""
from __future__ import annotations
import pytest
from picarones.app.services.benchmark_runner import build_adapter_resolver
from picarones.domain.errors import PicaronesError
# ──────────────────────────────────────────────────────────────────────
# Cas qui reproduit le bug en prod
# ──────────────────────────────────────────────────────────────────────
class TestStandaloneAndPipelineWithSameOCR:
"""Le scénario exact qui plantait en prod : Tesseract seul +
pipeline Tesseract+LLM avec la même config OCR."""
def test_two_competitors_same_tesseract_config_accepted(self) -> None:
from picarones.adapters.ocr.tesseract import TesseractAdapter
from picarones.adapters.llm.mistral_adapter import MistralAdapter
from picarones.pipeline.llm_pipeline_config import OCRLLMPipelineConfig
# Competitor 1 : Tesseract seul.
tesseract_standalone = TesseractAdapter(lang="fra", psm=6)
# Competitor 2 : pipeline Tesseract → Mistral. Le Tesseract
# interne est une AUTRE instance Python mais avec la même config.
tesseract_in_pipeline = TesseractAdapter(lang="fra", psm=6)
assert tesseract_standalone is not tesseract_in_pipeline, (
"test pré-condition : deux instances distinctes"
)
pipeline = OCRLLMPipelineConfig(
ocr_adapter=tesseract_in_pipeline,
llm_adapter=MistralAdapter(model="mistral-small-latest"),
mode="text_only",
# Template avec placeholder valide (la défense
# ``__post_init__`` Sprint S9 refuse les templates sans
# placeholder substituable — cf. test_s9_prompt_loading_defenses).
prompt_template="Corrige : {ocr_output}",
pipeline_name="tesseract:fra → mistral-small-latest",
)
# Le resolver doit accepter cette config — avant le fix
# S9, il levait ``PicaronesError``.
resolver = build_adapter_resolver(
[tesseract_standalone, pipeline],
)
# Résolution : le nom ``tesseract`` mappe vers UNE instance
# (la 1re, par convention idempotente).
resolved = resolver("tesseract")
assert resolved is tesseract_standalone
class TestDifferentConfigsStillCollide:
"""Garde-fou : si deux engines partagent le même ``name`` mais
une config différente, le resolver doit toujours rejeter — sinon
on cacherait silencieusement un vrai bug utilisateur."""
def test_different_lang_same_name_raises(self) -> None:
from picarones.adapters.ocr.tesseract import TesseractAdapter
# ``name="tesseract"`` par défaut, mais ``lang`` différent →
# vraies configs distinctes → collision réelle.
adapter_fra = TesseractAdapter(lang="fra", psm=6)
adapter_eng = TesseractAdapter(lang="eng", psm=6)
with pytest.raises(PicaronesError, match="enregistré deux fois|configurations différentes"):
build_adapter_resolver([adapter_fra, adapter_eng])
def test_different_psm_same_name_raises(self) -> None:
from picarones.adapters.ocr.tesseract import TesseractAdapter
adapter_psm6 = TesseractAdapter(lang="fra", psm=6)
adapter_psm3 = TesseractAdapter(lang="fra", psm=3)
with pytest.raises(PicaronesError, match="enregistré deux fois|configurations différentes"):
build_adapter_resolver([adapter_psm6, adapter_psm3])
def test_different_types_same_name_raises(self) -> None:
"""Deux types différents avec le même ``name`` (improbable
en pratique mais théoriquement possible si un caller
configure manuellement) → toujours rejeté."""
from picarones.adapters.ocr.tesseract import TesseractAdapter
from picarones.adapters.ocr.precomputed import (
PrecomputedTextAdapter,
)
# PrecomputedTextAdapter dérive son name de ``source_label`` ;
# on construit un Tesseract dont le name match exactement
# celui généré par PrecomputedTextAdapter.
precomputed = PrecomputedTextAdapter(source_label="bnf")
# ``precomputed.name`` est "precomputed:bnf" — utilisons un
# name compatible avec le validateur Tesseract (alphanum + _-).
tesseract = TesseractAdapter(name=precomputed.name.replace(":", "_"))
# Renommer mentalement pour forcer la collision : on force
# le même name côté Tesseract et PrecomputedTextAdapter
# via attribut interne (path direct au _dict pour test).
tesseract.__dict__["_name"] = precomputed.name
assert tesseract.name == precomputed.name
with pytest.raises(PicaronesError, match="enregistré deux fois|configurations différentes"):
build_adapter_resolver([tesseract, precomputed])
# ──────────────────────────────────────────────────────────────────────
# Idempotence : même instance enregistrée 2 fois
# ──────────────────────────────────────────────────────────────────────
class TestIdempotentRegistration:
def test_same_instance_twice_is_idempotent(self) -> None:
"""Si une instance est enregistrée deux fois (par ex. via
deux pipelines qui partagent la même réf d'adapter OCR),
c'est trivialement OK."""
from picarones.adapters.ocr.tesseract import TesseractAdapter
adapter = TesseractAdapter(lang="fra")
resolver = build_adapter_resolver([adapter, adapter])
assert resolver("tesseract") is adapter
|