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