Claude commited on
Commit
67d1086
·
unverified ·
1 Parent(s): f3b0e4a

feat(adapters): Sprint A14-S26 — BaseOCRAdapter natif + PrecomputedTextAdapter

Browse files

Premier adapter du nouveau monde, écrit en partant de zéro selon le
contrat propre du rewrite — pas de shim sur le legacy
``picarones.engines.base.BaseOCREngine``, pas de dette technique.

Pourquoi pas de shim
--------------------
Le legacy ``BaseOCREngine`` a un contrat historique
(``run(image_path) → EngineResult``, ``ArtifactType.TEXT``,
``EngineResult`` wrapper) qui ne correspond pas au contrat propre
du nouveau monde (``execute(inputs, params, context) →
dict[ArtifactType, Artifact]``, ``ArtifactType.RAW_TEXT``,
exceptions directes). Pontuiller via un adapter d'adapter
introduirait de la dette : chaque divergence de comportement
entre legacy et nouveau créerait des cas spéciaux.

Le rewrite écrit les adapters OCR depuis zéro, sprint par sprint,
chacun natif au nouveau contrat. Le legacy reste utilisable via
la CLI legacy historique tant qu'il a des consommateurs.

Contrat ``BaseOCRAdapter`` (``picarones/adapters/ocr/base.py``)
--------------------------------------------------------------
ABC du nouveau monde :

- Propriété abstraite ``name`` (identifiant lisible).
- Méthode abstraite
``execute(inputs, params, context) → dict[ArtifactType, Artifact]``.
- Attributs de classe par défaut : ``input_types`` (IMAGE),
``output_types`` (RAW_TEXT), ``execution_mode`` ("io"). Une
sous-classe surcharge si besoin (ex : moteur structuré qui
produit IMAGE → ALTO_XML).
- Erreur typée ``OCRAdapterError`` que le ``PipelineExecutor``
capture en step en échec.

Pas hérité de ``BaseModule`` (legacy core) — le rewrite redéfinit
ses propres abstractions.

Premier adapter livré : ``PrecomputedTextAdapter``
--------------------------------------------------
Lit du texte OCR pré-calculé depuis le filesystem selon une
convention de nommage :

::

folio_001.png
folio_001.tesseract.txt # source 1
folio_001.gpt4v.txt # source 2
folio_001.gt.txt # vérité terrain

Cas d'usage BnF concret : *« j'ai déjà fait tourner Tesseract,
GPT-4v, Pero OCR et un service cloud sur mon corpus. J'ai 4
répertoires de fichiers .txt à côté de mes images. Je veux
comparer ces 4 sorties dans Picarones — je n'ai pas besoin de
re-lancer un OCR. »*

Plusieurs ``PrecomputedTextAdapter`` peuvent coexister dans une
même YAML avec des ``source_label`` distincts ; chacun lit son
propre fichier.

Politique sur fichier manquant :

- ``"raise"`` (défaut) → ``OCRAdapterError``, le step est marqué
failed pour ce document, le benchmark continue.
- ``"empty"`` → fichier vide créé pour cohérence (toujours une
``Artifact.uri`` lisible) ; permet de mesurer ce que produirait
une source partiellement disponible.

Validation stricte du ``source_label`` (alphanumérique + ``_``
``-``), refus encodage non-UTF-8.

Bug fix annexe ``_build_pipelines``
-----------------------------------
La résolution d'``adapter_name`` dans ``picarones/app/cli/run.py``
disambiguait sur la classe seule. Or 3 ``PrecomputedTextAdapter``
instanciés avec des ``source_label`` différents partageaient la
**même** instance d'adapter (cache par nom de step partagé) — toutes
les pipelines recevaient le texte de la première source.

Fix : disambiguation sur ``(class, kwargs_signature)``. Deux
steps avec les mêmes class+kwargs partagent l'instance ; deux
steps avec class identique mais kwargs différents reçoivent des
``adapter_name`` distincts (préfixés par le nom de pipeline).

Tests (20 nouveaux)
-------------------
``tests/adapters/test_sprint_a14_s26_ocr_adapter.py`` :

- **Contrat ``BaseOCRAdapter``** (3 tests) : instanciation directe
rejetée, sous-classe minimale fonctionne, override des
attributs IO mode / types fonctionne.
- **Validation ``PrecomputedTextAdapter`` à l'init** (6 tests) :
source_label vide/whitespace/chars invalides rejetés ; libellés
valides acceptés ; missing_text_policy invalide rejeté ;
défaut ``"raise"``.
- **Exécution** (8 tests) : lecture par convention, fichier
manquant → raise (défaut), policy ``"empty"`` crée fichier vide,
encodage non-UTF-8 rejeté, IMAGE manquant / sans URI rejetés,
isolation 2 sources dans même répertoire, extensions
``.png/.jpg/.jpeg/.tif/.tiff`` toutes gérées.
- **Pipeline executor** (1 test) : adapter consommé directement
par ``PipelineExecutor.run`` — preuve que le contrat
``BaseOCRAdapter`` satisfait le ``StepExecutor``.
- **CLI E2E BnF** (2 tests) : YAML déclarant 3 sources
pré-calculées (tesseract / gpt4v / pero) → 3 pipelines exécutés
via ``picarones-rewrite run`` → 6 ViewResults dans TextView
avec gradient de CER (tesseract 0 < gpt4v < pero) ; cas fichier
manquant produit step en échec sans crash global.

554 tests sprint_a14 passent (534 S1-S25 + 20 S26).

L'utilisateur BnF peut désormais :
``picarones-rewrite run --spec my_run.yaml`` avec ses propres
sorties OCR déjà calculées, **sans aucun OCR engine installé**,
et obtenir un rapport HTML comparant N transcriptions.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

picarones/adapters/ocr/__init__.py CHANGED
@@ -1,16 +1,29 @@
1
- """Adaptateurs OCR — Sprint S11.
2
 
3
- Cible : déplacement (sans modification logique) de
4
- ``picarones.engines.{tesseract,pero_ocr,mistral_ocr,google_vision,
5
- azure_doc_intel}``. Chaque adapter implémente le protocole
6
- ``StepExecutor`` du package ``pipeline``.
7
 
8
- Règle : un adapter OCR produit un artefact ``RAW_TEXT`` (et
9
- optionnellement ``ALTO_XML`` / ``token_confidences``). Il ne
10
- calcule **rien** sur ce texte pas de CER, pas de normalisation,
11
- pas d'analyse linguistique. Tout ça est dans ``evaluation/``.
 
 
 
 
 
 
12
  """
13
 
14
  from __future__ import annotations
15
 
16
- __all__: list[str] = []
 
 
 
 
 
 
 
 
1
+ """Adapters OCR du nouveau monde — Sprint A14-S26.
2
 
3
+ Contrat ``BaseOCRAdapter`` natif au rewrite : pas hérité du legacy
4
+ ``picarones.engines.base.BaseOCREngine``, exprimé directement en
5
+ termes du nouveau ``ArtifactType`` et de l'interface
6
+ ``execute(inputs, params, context)`` du ``PipelineExecutor``.
7
 
8
+ Implémentations livrées
9
+ -----------------------
10
+ - ``PrecomputedTextAdapter``lit un texte OCR pré-calculé depuis
11
+ le filesystem. Cas BnF : comparer N transcriptions déjà produites
12
+ par d'autres outils sans relancer d'OCR.
13
+
14
+ Adapters concrets pour Tesseract / Pero OCR / Mistral OCR / Google
15
+ Vision / Azure DI : à écrire au cas par cas dans des sprints
16
+ dédiés, **natifs** au nouveau contrat (pas de shim sur le legacy
17
+ ``picarones.engines``).
18
  """
19
 
20
  from __future__ import annotations
21
 
22
+ from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
23
+ from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
24
+
25
+ __all__ = [
26
+ "BaseOCRAdapter",
27
+ "OCRAdapterError",
28
+ "PrecomputedTextAdapter",
29
+ ]
picarones/adapters/ocr/base.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``BaseOCRAdapter`` — contrat natif du nouveau monde pour un adapter OCR.
2
+
3
+ Sprint A14-S26 du rewrite ciblé.
4
+
5
+ Ce module définit le contrat **propre** auquel un adapter OCR du
6
+ nouveau monde doit se conformer pour être utilisable comme step
7
+ d'une pipeline ``picarones.pipeline``. Pas hérité du legacy
8
+ ``picarones.engines.base.BaseOCREngine`` — c'est un nouveau contrat,
9
+ sans dette technique, exprimé en termes du nouveau ``ArtifactType``.
10
+
11
+ Contrat
12
+ -------
13
+ Un adapter OCR :
14
+
15
+ - Déclare ses ``input_types`` (typiquement
16
+ ``frozenset({ArtifactType.IMAGE})``).
17
+ - Déclare ses ``output_types`` (typiquement
18
+ ``frozenset({ArtifactType.RAW_TEXT})``, ou plus pour les moteurs
19
+ structurés).
20
+ - Déclare son ``execution_mode`` : ``"io"`` (défaut, ThreadPool) ou
21
+ ``"cpu"`` (ProcessPool).
22
+ - Implémente
23
+ ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``.
24
+
25
+ Le ``Artifact`` retourné porte une ``uri`` filesystem — c'est la
26
+ convention du nouveau monde pour permettre au ``payload_loader`` de
27
+ le lire ultérieurement (Sprint S25 — la projection a un payload
28
+ direct, mais les artefacts produits par les adapters sont stockés
29
+ sur disque pour traçabilité et streaming).
30
+
31
+ Différences avec le legacy
32
+ --------------------------
33
+ - ``ArtifactType.RAW_TEXT`` (10 valeurs) au lieu de
34
+ ``ArtifactType.TEXT`` (6 valeurs legacy).
35
+ - Pas de ``run(image_path)`` historique — un seul point d'entrée
36
+ ``execute()``.
37
+ - Pas de wrapper ``EngineResult`` — les erreurs lèvent directement,
38
+ le ``PipelineExecutor`` les capture en step en échec.
39
+ - Pas de ``_run_ocr`` / ``_run_with_native`` / ``_extract_raw_confidences``
40
+ — les confidences (S42 legacy) sont reportées à un sprint dédié
41
+ où l'on définira un ``ConfidenceArtifact`` typé.
42
+
43
+ Anti-sur-ingénierie
44
+ -------------------
45
+ - Pas de hiérarchie d'erreurs. Un adapter qui échoue lève
46
+ ``OCRAdapterError`` (ou laisse passer une exception). Le
47
+ ``PipelineExecutor`` (S7) catch et marque le step en échec.
48
+ - Pas de cache au niveau de l'ABC. Si un adapter veut cacher ses
49
+ résultats, c'est dans son implémentation (compose ``ArtifactStore``
50
+ S7 si besoin).
51
+ - Pas de retry. Idem.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from abc import ABC, abstractmethod
57
+ from typing import Any
58
+
59
+ from picarones.domain.artifacts import Artifact, ArtifactType
60
+
61
+
62
+ class OCRAdapterError(Exception):
63
+ """Erreur typée pour un échec d'adapter OCR du nouveau monde.
64
+
65
+ Le ``PipelineExecutor`` capture cette exception (et toute autre)
66
+ et marque le step correspondant comme failed avec
67
+ ``StepResult.error`` renseigné. Les callers downstream
68
+ (``BenchmarkService``, vues) verront le pipeline en échec sans
69
+ crash global.
70
+ """
71
+
72
+
73
+ class BaseOCRAdapter(ABC):
74
+ """Classe de base pour un adapter OCR du nouveau monde.
75
+
76
+ Toute sous-classe doit :
77
+
78
+ 1. Surcharger la propriété ``name`` (identifiant lisible, utilisé
79
+ dans les ``Artifact.id`` et le run_manifest).
80
+ 2. Implémenter ``execute(inputs, params, context)``.
81
+
82
+ Les attributs de classe ``input_types`` / ``output_types`` /
83
+ ``execution_mode`` sont fournis par défaut pour le cas le plus
84
+ courant (image → texte, IO-bound). Une sous-classe qui produit
85
+ de l'ALTO surcharge ``output_types``, etc.
86
+
87
+ Exemple
88
+ -------
89
+
90
+ ::
91
+
92
+ class MyOCRAdapter(BaseOCRAdapter):
93
+ @property
94
+ def name(self) -> str:
95
+ return "my_ocr"
96
+
97
+ def execute(self, inputs, params, context):
98
+ image_artifact = inputs[ArtifactType.IMAGE]
99
+ # ... appel OCR sur image_artifact.uri ...
100
+ # ... écriture du résultat sur disque ...
101
+ return {
102
+ ArtifactType.RAW_TEXT: Artifact(
103
+ id=f"{context.document_id}:{self.name}:raw_text",
104
+ document_id=context.document_id,
105
+ type=ArtifactType.RAW_TEXT,
106
+ produced_by_step="ocr",
107
+ uri=str(out_path),
108
+ ),
109
+ }
110
+ """
111
+
112
+ #: Types d'artefacts attendus en entrée. Le ``PipelineExecutor``
113
+ #: utilise cette info pour valider la compatibilité des steps
114
+ #: enchaînés.
115
+ input_types: frozenset[ArtifactType] = frozenset({ArtifactType.IMAGE})
116
+
117
+ #: Types d'artefacts produits. Validés à la sortie de ``execute``.
118
+ output_types: frozenset[ArtifactType] = frozenset({ArtifactType.RAW_TEXT})
119
+
120
+ #: ``"io"`` (ThreadPool) ou ``"cpu"`` (ProcessPool). Indique au
121
+ #: runner quel type de pool utiliser pour la concurrence.
122
+ execution_mode: str = "io"
123
+
124
+ @property
125
+ @abstractmethod
126
+ def name(self) -> str:
127
+ """Identifiant lisible de l'adapter (ex : ``"tesseract"``,
128
+ ``"precomputed_text"``). Utilisé dans les ``Artifact.id`` du
129
+ nouveau monde et dans le ``run_manifest``."""
130
+
131
+ @abstractmethod
132
+ def execute(
133
+ self,
134
+ inputs: dict[ArtifactType, Artifact],
135
+ params: dict[str, Any],
136
+ context: Any,
137
+ ) -> dict[ArtifactType, Artifact]:
138
+ """Exécute l'OCR sur les entrées et retourne les artefacts produits.
139
+
140
+ Parameters
141
+ ----------
142
+ inputs:
143
+ Map ``ArtifactType → Artifact`` avec au minimum les types
144
+ déclarés dans ``self.input_types``. L'adapter peut
145
+ ignorer les entrées surnuméraires.
146
+ params:
147
+ Paramètres dynamiques du step (typiquement vides — la
148
+ configuration de l'adapter passe par son constructeur).
149
+ context:
150
+ ``RunContext`` du run en cours (porte ``document_id``,
151
+ ``code_version``, ``pipeline_name``).
152
+
153
+ Returns
154
+ -------
155
+ dict[ArtifactType, Artifact]
156
+ Map des artefacts produits. Doit contenir au moins les
157
+ types déclarés dans ``self.output_types``.
158
+
159
+ Raises
160
+ ------
161
+ OCRAdapterError
162
+ Erreur typée pour signaler un échec côté adapter (input
163
+ invalide, fichier introuvable, etc.).
164
+ """
165
+
166
+
167
+ __all__ = ["BaseOCRAdapter", "OCRAdapterError"]
picarones/adapters/ocr/precomputed.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``PrecomputedTextAdapter`` — premier adapter natif du nouveau monde.
2
+
3
+ Sprint A14-S26 du rewrite ciblé.
4
+
5
+ Cas d'usage BnF
6
+ ---------------
7
+ *« J'ai déjà fait tourner Tesseract, GPT-4-vision, Pero OCR et un
8
+ service cloud sur mon corpus. J'ai 4 répertoires de fichiers
9
+ ``.txt`` à côté de mes images. Je veux comparer ces 4 sorties dans
10
+ Picarones — je n'ai pas besoin de re-lancer un OCR, j'ai juste besoin
11
+ de la machinerie d'évaluation. »*
12
+
13
+ Ce besoin est légitime et fréquent à la BnF : une part importante
14
+ du travail de comparaison se fait sur des transcriptions déjà
15
+ produites par d'autres outils. Ré-exécuter un OCR à chaque
16
+ benchmark est gaspillage.
17
+
18
+ Convention de nommage
19
+ ---------------------
20
+ Pour une image ``<stem>.png`` (ou ``.jpg``, ``.tif``, etc.), le
21
+ texte pré-calculé est lu depuis :
22
+
23
+ ::
24
+
25
+ <stem>.<source_label>.txt
26
+
27
+ dans le **même répertoire** que l'image. Exemple avec deux
28
+ sources concurrentes :
29
+
30
+ ::
31
+
32
+ folio_001.png
33
+ folio_001.tesseract.txt # produit par Tesseract
34
+ folio_001.pero.txt # produit par Pero OCR
35
+ folio_001.gpt4v.txt # produit par GPT-4 Vision
36
+ folio_001.gt.txt # vérité terrain
37
+
38
+ Plusieurs ``PrecomputedTextAdapter`` peuvent coexister dans une
39
+ même YAML avec des ``source_label`` distincts — chacun lit son
40
+ propre fichier, le ``BenchmarkService`` les traite en parallèle.
41
+
42
+ Configuration YAML
43
+ ------------------
44
+
45
+ ::
46
+
47
+ pipelines:
48
+ - name: tesseract_baseline
49
+ initial_inputs: [image]
50
+ steps:
51
+ - id: ocr
52
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
53
+ adapter_kwargs:
54
+ source_label: tesseract
55
+ input_types: [image]
56
+ output_types: [raw_text]
57
+
58
+ - name: gpt4v_alternative
59
+ initial_inputs: [image]
60
+ steps:
61
+ - id: ocr
62
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
63
+ adapter_kwargs:
64
+ source_label: gpt4v
65
+ input_types: [image]
66
+ output_types: [raw_text]
67
+
68
+ Comportement « fichier manquant »
69
+ ---------------------------------
70
+ Par défaut, si le fichier ``<stem>.<source_label>.txt`` est absent,
71
+ l'adapter lève ``OCRAdapterError`` — le pipeline executor marque le
72
+ step comme failed pour ce document, et le ``BenchmarkService`` le
73
+ voit en ``failed_metrics``. Pas de fallback silencieux qui
74
+ mentirait sur la couverture du benchmark.
75
+
76
+ L'option ``missing_text_policy="empty"`` permet, à la demande
77
+ explicite du caller, de remplacer un fichier absent par une chaîne
78
+ vide — utile pour mesurer ce qui se passerait si une source était
79
+ indisponible sur certains documents. Par défaut : ``"raise"``.
80
+
81
+ Anti-sur-ingénierie
82
+ -------------------
83
+ - Pas de découverte automatique de tous les ``source_label``
84
+ présents dans un répertoire. Le caller déclare explicitement
85
+ les sources qu'il veut comparer.
86
+ - Pas de cache. Le filesystem fait son boulot.
87
+ - Pas de validation d'encodage exotique. ``utf-8`` strict ; un
88
+ fichier mal encodé lève une erreur lisible.
89
+ - Pas d'extraction structurelle. Cet adapter sort du ``RAW_TEXT``,
90
+ point. Pour comparer des ALTO_XML pré-calculés, c'est un
91
+ ``PrecomputedAltoAdapter`` futur (pattern identique).
92
+ """
93
+
94
+ from __future__ import annotations
95
+
96
+ from pathlib import Path
97
+ from typing import Any, Literal
98
+
99
+ from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
100
+ from picarones.domain.artifacts import Artifact, ArtifactType
101
+
102
+
103
+ class PrecomputedTextAdapter(BaseOCRAdapter):
104
+ """Adapter qui lit du texte OCR pré-calculé depuis le filesystem.
105
+
106
+ Parameters
107
+ ----------
108
+ source_label:
109
+ Étiquette identifiant la source du texte pré-calculé
110
+ (ex : ``"tesseract"``, ``"gpt4v"``, ``"pero"``). Doit être
111
+ composée uniquement de caractères alphanumériques, ``_`` et
112
+ ``-`` — c'est un composant de nom de fichier.
113
+ missing_text_policy:
114
+ ``"raise"`` (défaut) → fichier absent lève ``OCRAdapterError``.
115
+ ``"empty"`` → fichier absent remplacé par chaîne vide
116
+ (l'adapter produit alors un ``Artifact`` pointant sur un
117
+ fichier vide).
118
+
119
+ Raises
120
+ ------
121
+ OCRAdapterError
122
+ Si ``source_label`` est invalide.
123
+ """
124
+
125
+ input_types = frozenset({ArtifactType.IMAGE})
126
+ output_types = frozenset({ArtifactType.RAW_TEXT})
127
+ execution_mode = "io"
128
+
129
+ def __init__(
130
+ self,
131
+ *,
132
+ source_label: str,
133
+ missing_text_policy: Literal["raise", "empty"] = "raise",
134
+ ) -> None:
135
+ if not source_label or not source_label.strip():
136
+ raise OCRAdapterError(
137
+ "PrecomputedTextAdapter : source_label vide.",
138
+ )
139
+ if not all(
140
+ c.isalnum() or c in "_-" for c in source_label
141
+ ):
142
+ raise OCRAdapterError(
143
+ f"PrecomputedTextAdapter : source_label invalide "
144
+ f"{source_label!r} — alphanumérique + _ - uniquement.",
145
+ )
146
+ if missing_text_policy not in ("raise", "empty"):
147
+ raise OCRAdapterError(
148
+ f"missing_text_policy doit être 'raise' ou 'empty', "
149
+ f"reçu {missing_text_policy!r}.",
150
+ )
151
+ self._source_label = source_label
152
+ self._missing_policy = missing_text_policy
153
+
154
+ @property
155
+ def name(self) -> str:
156
+ return f"precomputed_{self._source_label}"
157
+
158
+ @property
159
+ def source_label(self) -> str:
160
+ return self._source_label
161
+
162
+ def execute(
163
+ self,
164
+ inputs: dict[ArtifactType, Artifact],
165
+ params: dict[str, Any],
166
+ context: Any,
167
+ ) -> dict[ArtifactType, Artifact]:
168
+ if ArtifactType.IMAGE not in inputs:
169
+ raise OCRAdapterError(
170
+ f"{self.name} : input IMAGE manquant.",
171
+ )
172
+ image_artifact = inputs[ArtifactType.IMAGE]
173
+ if image_artifact.uri is None:
174
+ raise OCRAdapterError(
175
+ f"{self.name} : artefact image "
176
+ f"{image_artifact.id!r} sans URI.",
177
+ )
178
+
179
+ image_path = Path(image_artifact.uri)
180
+ text_path = (
181
+ image_path.parent / f"{image_path.stem}.{self._source_label}.txt"
182
+ )
183
+
184
+ if not text_path.exists():
185
+ if self._missing_policy == "empty":
186
+ # On crée le fichier vide pour rester cohérent : tout
187
+ # ``Artifact`` produit a une URI vers un fichier
188
+ # lisible.
189
+ text_path.write_text("", encoding="utf-8")
190
+ else:
191
+ raise OCRAdapterError(
192
+ f"{self.name} : fichier pré-calculé introuvable "
193
+ f"pour {image_path.name!r} : "
194
+ f"{text_path.name!r} attendu dans "
195
+ f"{image_path.parent!r}.",
196
+ )
197
+
198
+ # Validation rapide de l'encodage UTF-8 (lecture qui leverait
199
+ # si encodage exotique).
200
+ try:
201
+ text_path.read_text(encoding="utf-8")
202
+ except UnicodeDecodeError as exc:
203
+ raise OCRAdapterError(
204
+ f"{self.name} : {text_path!r} n'est pas en UTF-8 : "
205
+ f"{exc}",
206
+ ) from exc
207
+
208
+ return {
209
+ ArtifactType.RAW_TEXT: Artifact(
210
+ id=f"{context.document_id}:{self.name}:raw_text",
211
+ document_id=context.document_id,
212
+ type=ArtifactType.RAW_TEXT,
213
+ produced_by_step="ocr",
214
+ uri=str(text_path),
215
+ ),
216
+ }
217
+
218
+
219
+ __all__ = ["PrecomputedTextAdapter"]
picarones/app/cli/run.py CHANGED
@@ -248,26 +248,46 @@ def _build_pipelines(spec: RunSpec) -> tuple[
248
  """Construit les ``PipelineSpec`` + un ``adapter_resolver`` qui
249
  instancie les adapters au besoin.
250
 
251
- Le resolver maintient un cache instance-par-nom (un adapter est
252
- instancié une seule fois pour tout le run).
 
 
 
 
 
 
 
 
 
 
253
  """
254
  instance_cache: dict[str, object] = {}
 
 
 
255
  name_to_class: dict[str, type] = {}
256
  name_to_kwargs: dict[str, dict] = {}
257
 
 
 
 
 
 
 
258
  pipeline_specs: list[PipelineSpec] = []
259
  for p in spec.pipelines:
260
  steps = []
261
  for s in p.steps:
262
  cls = resolve_adapter_class(s.adapter_class)
 
263
  adapter_name = s.id
264
- # Si le même step.id apparaît dans deux pipelines avec
265
- # des classes différentes, on disambiguë par la pipeline.
266
- if (
267
- adapter_name in name_to_class
268
- and name_to_class[adapter_name] is not cls
269
- ):
270
  adapter_name = f"{p.name}__{s.id}"
 
271
  name_to_class[adapter_name] = cls
272
  name_to_kwargs[adapter_name] = s.adapter_kwargs
273
  steps.append(PipelineStep(
 
248
  """Construit les ``PipelineSpec`` + un ``adapter_resolver`` qui
249
  instancie les adapters au besoin.
250
 
251
+ Disambiguation des steps :
252
+
253
+ - Deux steps qui ont la même ``(class, kwargs)`` partagent la
254
+ même instance d'adapter (cache).
255
+ - Deux steps qui ont la même ``id`` mais une ``class`` ou des
256
+ ``kwargs`` différents reçoivent des ``adapter_name`` distincts
257
+ (préfixés par le nom de pipeline).
258
+
259
+ C'est essentiel pour le cas BnF où plusieurs pipelines utilisent
260
+ la **même classe** avec des **kwargs différents** (ex :
261
+ ``PrecomputedTextAdapter`` instancié 3 fois avec
262
+ ``source_label`` distincts).
263
  """
264
  instance_cache: dict[str, object] = {}
265
+ # ``adapter_name`` → ``(class, kwargs_signature)`` pour valider
266
+ # qu'on n'écrase pas un adapter avec un autre.
267
+ registered: dict[str, tuple[type, str]] = {}
268
  name_to_class: dict[str, type] = {}
269
  name_to_kwargs: dict[str, dict] = {}
270
 
271
+ def _kwargs_signature(kwargs: dict) -> str:
272
+ # Signature stable : ordre de tri sur les clés. ``str(value)``
273
+ # suffit pour comparer — les types JSON-serializable seront
274
+ # déterministes.
275
+ return "|".join(f"{k}={kwargs[k]!r}" for k in sorted(kwargs))
276
+
277
  pipeline_specs: list[PipelineSpec] = []
278
  for p in spec.pipelines:
279
  steps = []
280
  for s in p.steps:
281
  cls = resolve_adapter_class(s.adapter_class)
282
+ kwargs_sig = _kwargs_signature(s.adapter_kwargs)
283
  adapter_name = s.id
284
+ # Si le step.id existe déjà mais avec une autre signature
285
+ # (class ou kwargs distincts), on disambigue en préfixant
286
+ # par le nom de la pipeline.
287
+ existing = registered.get(adapter_name)
288
+ if existing is not None and existing != (cls, kwargs_sig):
 
289
  adapter_name = f"{p.name}__{s.id}"
290
+ registered[adapter_name] = (cls, kwargs_sig)
291
  name_to_class[adapter_name] = cls
292
  name_to_kwargs[adapter_name] = s.adapter_kwargs
293
  steps.append(PipelineStep(
tests/adapters/__init__.py ADDED
File without changes
tests/adapters/test_sprint_a14_s26_ocr_adapter.py ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S26 — ``BaseOCRAdapter`` + ``PrecomputedTextAdapter``.
2
+
3
+ Couverture :
4
+
5
+ - **Contrat** : un ``BaseOCRAdapter`` est instanciable, expose
6
+ ``name`` / ``input_types`` / ``output_types`` / ``execution_mode``,
7
+ son ``execute()`` est abstrait.
8
+ - **PrecomputedTextAdapter** : validation du ``source_label``,
9
+ lecture filesystem par convention de nommage, politique
10
+ ``"raise"`` vs ``"empty"`` sur fichier manquant, validation
11
+ UTF-8, isolation entre instances de sources distinctes.
12
+ - **Pipeline executor** : un ``PrecomputedTextAdapter`` est consommé
13
+ directement par le ``PipelineExecutor`` (S7) — preuve que le
14
+ contrat ``BaseOCRAdapter`` satisfait ``StepExecutor``.
15
+ - **CLI E2E** : YAML déclarant 3 sources pré-calculées différentes
16
+ → benchmark complet avec 3 pipelines comparés sur TextView,
17
+ sans aucun OCR réel.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import json
24
+ import textwrap
25
+ import zipfile
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+ from click.testing import CliRunner
30
+
31
+ from picarones.adapters.ocr import (
32
+ BaseOCRAdapter,
33
+ OCRAdapterError,
34
+ PrecomputedTextAdapter,
35
+ )
36
+ from picarones.app.cli import cli
37
+ from picarones.domain.artifacts import Artifact, ArtifactType
38
+ from picarones.pipeline.types import RunContext
39
+
40
+
41
+ # ──────────────────────────────────────────────────────────────────
42
+ # Fixtures
43
+ # ──────────────────────────────────────────────────────────────────
44
+
45
+
46
+ def _png_bytes() -> bytes:
47
+ return (
48
+ b"\x89PNG\r\n\x1a\n"
49
+ b"\x00\x00\x00\rIHDR"
50
+ b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00"
51
+ b"\x1f\x15\xc4\x89"
52
+ )
53
+
54
+
55
+ def _ctx(doc_id: str = "doc01") -> RunContext:
56
+ return RunContext(
57
+ document_id=doc_id,
58
+ code_version="1.0.0-s26-test",
59
+ pipeline_name="test_pipeline",
60
+ )
61
+
62
+
63
+ def _image_artifact(doc_id: str, path: Path) -> Artifact:
64
+ return Artifact(
65
+ id=f"{doc_id}:image",
66
+ document_id=doc_id,
67
+ type=ArtifactType.IMAGE,
68
+ uri=str(path),
69
+ )
70
+
71
+
72
+ # ──────────────────────────────────────────────────────────────────
73
+ # Contrat BaseOCRAdapter
74
+ # ──────────────────────────────────────────────────────────────────
75
+
76
+
77
+ class TestBaseOCRAdapterContract:
78
+ def test_cannot_instantiate_abstract_directly(self) -> None:
79
+ with pytest.raises(TypeError):
80
+ BaseOCRAdapter() # type: ignore[abstract]
81
+
82
+ def test_minimal_subclass_with_name_and_execute_works(self) -> None:
83
+ class _Minimal(BaseOCRAdapter):
84
+ @property
85
+ def name(self) -> str:
86
+ return "minimal"
87
+
88
+ def execute(self, inputs, params, context):
89
+ return {}
90
+
91
+ adapter = _Minimal()
92
+ assert adapter.name == "minimal"
93
+ assert ArtifactType.IMAGE in adapter.input_types
94
+ assert ArtifactType.RAW_TEXT in adapter.output_types
95
+ assert adapter.execution_mode == "io"
96
+
97
+ def test_subclass_can_override_io_modes(self) -> None:
98
+ class _CPUBound(BaseOCRAdapter):
99
+ execution_mode = "cpu"
100
+ input_types = frozenset({ArtifactType.IMAGE})
101
+ output_types = frozenset({
102
+ ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML,
103
+ })
104
+
105
+ @property
106
+ def name(self) -> str:
107
+ return "cpu_bound"
108
+
109
+ def execute(self, inputs, params, context):
110
+ return {}
111
+
112
+ adapter = _CPUBound()
113
+ assert adapter.execution_mode == "cpu"
114
+ assert ArtifactType.ALTO_XML in adapter.output_types
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────
118
+ # PrecomputedTextAdapter — validation à l'init
119
+ # ──────────────────────────────────────────────────────────────────
120
+
121
+
122
+ class TestPrecomputedInitValidation:
123
+ def test_empty_source_label_rejected(self) -> None:
124
+ with pytest.raises(OCRAdapterError, match="vide"):
125
+ PrecomputedTextAdapter(source_label="")
126
+
127
+ def test_whitespace_source_label_rejected(self) -> None:
128
+ with pytest.raises(OCRAdapterError, match="vide"):
129
+ PrecomputedTextAdapter(source_label=" ")
130
+
131
+ def test_invalid_chars_in_source_label_rejected(self) -> None:
132
+ for bad in ("foo/bar", "foo bar", "foo.bar", "foo:bar"):
133
+ with pytest.raises(OCRAdapterError, match="invalide"):
134
+ PrecomputedTextAdapter(source_label=bad)
135
+
136
+ def test_valid_source_labels_accepted(self) -> None:
137
+ for good in ("tesseract", "gpt-4v", "pero_ocr", "ABC123"):
138
+ adapter = PrecomputedTextAdapter(source_label=good)
139
+ assert adapter.source_label == good
140
+ assert adapter.name == f"precomputed_{good}"
141
+
142
+ def test_invalid_missing_text_policy_rejected(self) -> None:
143
+ with pytest.raises(OCRAdapterError, match="missing_text_policy"):
144
+ PrecomputedTextAdapter(
145
+ source_label="tess",
146
+ missing_text_policy="silent", # type: ignore[arg-type]
147
+ )
148
+
149
+ def test_default_missing_text_policy_is_raise(self) -> None:
150
+ adapter = PrecomputedTextAdapter(source_label="tess")
151
+ assert adapter._missing_policy == "raise"
152
+
153
+
154
+ # ──────────────────────────────────────────────────────────────────
155
+ # PrecomputedTextAdapter — exécution
156
+ # ──────────────────────────────────────────────────────────────────
157
+
158
+
159
+ class TestPrecomputedExecute:
160
+ def test_reads_text_file_by_convention(self, tmp_path: Path) -> None:
161
+ # Préparer image + texte pré-calculé.
162
+ image_path = tmp_path / "doc01.png"
163
+ image_path.write_bytes(_png_bytes())
164
+ text_path = tmp_path / "doc01.tesseract.txt"
165
+ text_path.write_text("Bonjour le monde", encoding="utf-8")
166
+
167
+ adapter = PrecomputedTextAdapter(source_label="tesseract")
168
+ outputs = adapter.execute(
169
+ inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
170
+ params={},
171
+ context=_ctx("doc01"),
172
+ )
173
+ art = outputs[ArtifactType.RAW_TEXT]
174
+ assert art.type == ArtifactType.RAW_TEXT
175
+ assert art.document_id == "doc01"
176
+ assert Path(art.uri).read_text(encoding="utf-8") == "Bonjour le monde"
177
+ # Convention <doc_id>:<owner>:<role>.
178
+ assert art.id == "doc01:precomputed_tesseract:raw_text"
179
+
180
+ def test_missing_text_raises_by_default(self, tmp_path: Path) -> None:
181
+ image_path = tmp_path / "doc01.png"
182
+ image_path.write_bytes(_png_bytes())
183
+ # Pas de doc01.tesseract.txt.
184
+
185
+ adapter = PrecomputedTextAdapter(source_label="tesseract")
186
+ with pytest.raises(OCRAdapterError, match="introuvable"):
187
+ adapter.execute(
188
+ inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
189
+ params={},
190
+ context=_ctx("doc01"),
191
+ )
192
+
193
+ def test_missing_text_empty_policy_creates_empty_file(
194
+ self, tmp_path: Path,
195
+ ) -> None:
196
+ image_path = tmp_path / "doc01.png"
197
+ image_path.write_bytes(_png_bytes())
198
+
199
+ adapter = PrecomputedTextAdapter(
200
+ source_label="tess",
201
+ missing_text_policy="empty",
202
+ )
203
+ outputs = adapter.execute(
204
+ inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
205
+ params={},
206
+ context=_ctx("doc01"),
207
+ )
208
+ art = outputs[ArtifactType.RAW_TEXT]
209
+ assert Path(art.uri).read_text(encoding="utf-8") == ""
210
+
211
+ def test_non_utf8_file_rejected(self, tmp_path: Path) -> None:
212
+ image_path = tmp_path / "doc01.png"
213
+ image_path.write_bytes(_png_bytes())
214
+ text_path = tmp_path / "doc01.tess.txt"
215
+ # Bytes invalides en UTF-8 (latin-1 avec accent).
216
+ text_path.write_bytes(b"\xe9\xe8")
217
+
218
+ adapter = PrecomputedTextAdapter(source_label="tess")
219
+ with pytest.raises(OCRAdapterError, match="UTF-8"):
220
+ adapter.execute(
221
+ inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
222
+ params={},
223
+ context=_ctx("doc01"),
224
+ )
225
+
226
+ def test_missing_image_input_rejected(self, tmp_path: Path) -> None:
227
+ adapter = PrecomputedTextAdapter(source_label="tess")
228
+ with pytest.raises(OCRAdapterError, match="IMAGE manquant"):
229
+ adapter.execute(inputs={}, params={}, context=_ctx())
230
+
231
+ def test_image_artifact_without_uri_rejected(self) -> None:
232
+ adapter = PrecomputedTextAdapter(source_label="tess")
233
+ with pytest.raises(OCRAdapterError, match="sans URI"):
234
+ adapter.execute(
235
+ inputs={
236
+ ArtifactType.IMAGE: Artifact(
237
+ id="d:image", document_id="d",
238
+ type=ArtifactType.IMAGE,
239
+ ),
240
+ },
241
+ params={},
242
+ context=_ctx(),
243
+ )
244
+
245
+ def test_two_sources_isolated_in_same_dir(self, tmp_path: Path) -> None:
246
+ """Cas BnF central : deux sources pré-calculées dans le même
247
+ répertoire ne se piétinent pas — chaque adapter lit son
248
+ propre fichier."""
249
+ image_path = tmp_path / "doc01.png"
250
+ image_path.write_bytes(_png_bytes())
251
+ (tmp_path / "doc01.tess.txt").write_text(
252
+ "tesseract output", encoding="utf-8",
253
+ )
254
+ (tmp_path / "doc01.gpt4v.txt").write_text(
255
+ "gpt-4 vision output", encoding="utf-8",
256
+ )
257
+
258
+ a_tess = PrecomputedTextAdapter(source_label="tess")
259
+ a_gpt = PrecomputedTextAdapter(source_label="gpt4v")
260
+
261
+ out_tess = a_tess.execute(
262
+ inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
263
+ params={},
264
+ context=_ctx("doc01"),
265
+ )
266
+ out_gpt = a_gpt.execute(
267
+ inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)},
268
+ params={},
269
+ context=_ctx("doc01"),
270
+ )
271
+ assert Path(out_tess[ArtifactType.RAW_TEXT].uri).read_text() \
272
+ == "tesseract output"
273
+ assert Path(out_gpt[ArtifactType.RAW_TEXT].uri).read_text() \
274
+ == "gpt-4 vision output"
275
+
276
+ def test_image_extension_variations_handled(
277
+ self, tmp_path: Path,
278
+ ) -> None:
279
+ """``stem`` strip toutes les extensions image courantes."""
280
+ for ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff"):
281
+ image_path = tmp_path / f"folio_001{ext}"
282
+ image_path.write_bytes(_png_bytes())
283
+ text_path = tmp_path / "folio_001.src.txt"
284
+ text_path.write_text("ok", encoding="utf-8")
285
+
286
+ adapter = PrecomputedTextAdapter(source_label="src")
287
+ out = adapter.execute(
288
+ inputs={
289
+ ArtifactType.IMAGE: _image_artifact("folio_001", image_path),
290
+ },
291
+ params={},
292
+ context=_ctx("folio_001"),
293
+ )
294
+ assert Path(out[ArtifactType.RAW_TEXT].uri).read_text() == "ok"
295
+
296
+
297
+ # ──────────────────────────────────────────────────────────────────
298
+ # Smoke pipeline executor
299
+ # ──────────────────────────────────────────────────────────────────
300
+
301
+
302
+ class TestPipelineExecutorIntegration:
303
+ def test_adapter_consumed_by_pipeline_executor(
304
+ self, tmp_path: Path,
305
+ ) -> None:
306
+ """Démontre que ``BaseOCRAdapter`` satisfait le contrat
307
+ ``StepExecutor`` du nouveau pipeline executor — preuve que
308
+ le contrat propre du nouveau monde est suffisant."""
309
+ from picarones.domain.documents import DocumentRef
310
+ from picarones.pipeline import (
311
+ PipelineExecutor, PipelineSpec, PipelineStep,
312
+ )
313
+
314
+ image_path = tmp_path / "doc01.png"
315
+ image_path.write_bytes(_png_bytes())
316
+ (tmp_path / "doc01.tess.txt").write_text(
317
+ "Bonjour", encoding="utf-8",
318
+ )
319
+
320
+ adapter = PrecomputedTextAdapter(source_label="tess")
321
+ spec = PipelineSpec(
322
+ name="precomputed_smoke",
323
+ initial_inputs=(ArtifactType.IMAGE,),
324
+ steps=(PipelineStep(
325
+ id="ocr", kind="ocr",
326
+ adapter_name="precomputed",
327
+ input_types=(ArtifactType.IMAGE,),
328
+ output_types=(ArtifactType.RAW_TEXT,),
329
+ ),),
330
+ )
331
+ executor = PipelineExecutor(adapter_resolver=lambda n: adapter)
332
+ result = executor.run(
333
+ spec=spec,
334
+ document=DocumentRef(id="doc01", image_uri=str(image_path)),
335
+ initial_inputs={
336
+ ArtifactType.IMAGE: _image_artifact("doc01", image_path),
337
+ },
338
+ context=_ctx("doc01"),
339
+ )
340
+ assert result.succeeded
341
+ text_arts = result.artifacts_of_type(ArtifactType.RAW_TEXT)
342
+ assert len(text_arts) == 1
343
+ assert Path(text_arts[0].uri).read_text() == "Bonjour"
344
+
345
+
346
+ # ──────────────────────────────────────────────────────────────────
347
+ # CLI E2E : 3 sources pré-calculées comparées via picarones-rewrite run
348
+ # ──────────────────────────────────────────────────────────────────
349
+
350
+
351
+ def _make_corpus_zip_with_sources() -> bytes:
352
+ """Corpus avec image + GT + 3 sources pré-calculées."""
353
+ buf = io.BytesIO()
354
+ with zipfile.ZipFile(buf, mode="w") as zf:
355
+ for doc_id in ("doc01", "doc02"):
356
+ zf.writestr(f"{doc_id}.png", _png_bytes())
357
+ zf.writestr(f"{doc_id}.gt.txt", "Bonjour le monde")
358
+ # Tesseract : copie exacte de la GT (CER 0).
359
+ zf.writestr(
360
+ f"{doc_id}.tesseract.txt",
361
+ "Bonjour le monde",
362
+ )
363
+ # GPT-4v : 1 erreur (CER > 0).
364
+ zf.writestr(
365
+ f"{doc_id}.gpt4v.txt",
366
+ "Bonjur le monde",
367
+ )
368
+ # Pero : très dégradé.
369
+ zf.writestr(
370
+ f"{doc_id}.pero.txt",
371
+ "Bonjour 1e mond",
372
+ )
373
+ return buf.getvalue()
374
+
375
+
376
+ class TestCLIComparingPrecomputedSources:
377
+ """Cas BnF concret : « j'ai 3 transcriptions déjà produites,
378
+ je veux les comparer ».
379
+
380
+ YAML déclare 3 pipelines, chacun pointant sur
381
+ ``PrecomputedTextAdapter`` avec un ``source_label`` distinct.
382
+ Le ``BenchmarkService`` les exécute en parallèle, le
383
+ ``ReportService`` les compare dans TextView. Aucun OCR réel
384
+ n'est lancé."""
385
+
386
+ def test_three_precomputed_sources_compared_via_cli(
387
+ self, tmp_path: Path,
388
+ ) -> None:
389
+ runner = CliRunner()
390
+ corpus_zip = tmp_path / "corpus.zip"
391
+ corpus_zip.write_bytes(_make_corpus_zip_with_sources())
392
+
393
+ spec_path = tmp_path / "run.yaml"
394
+ out_dir = tmp_path / "out"
395
+ report_path = out_dir / "rapport.html"
396
+ spec_path.write_text(textwrap.dedent(f"""
397
+ corpus_zip: {corpus_zip}
398
+ corpus_name: bnf_3sources
399
+ pipelines:
400
+ - name: tesseract_baseline
401
+ initial_inputs: [image]
402
+ steps:
403
+ - id: ocr
404
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
405
+ adapter_kwargs:
406
+ source_label: tesseract
407
+ input_types: [image]
408
+ output_types: [raw_text]
409
+ - name: gpt4v_alternative
410
+ initial_inputs: [image]
411
+ steps:
412
+ - id: ocr
413
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
414
+ adapter_kwargs:
415
+ source_label: gpt4v
416
+ input_types: [image]
417
+ output_types: [raw_text]
418
+ - name: pero_alternative
419
+ initial_inputs: [image]
420
+ steps:
421
+ - id: ocr
422
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
423
+ adapter_kwargs:
424
+ source_label: pero
425
+ input_types: [image]
426
+ output_types: [raw_text]
427
+ views: [text_final]
428
+ output_dir: {out_dir}
429
+ report_html: {report_path}
430
+ code_version: "1.0.0-s26-bnf"
431
+ """))
432
+
433
+ result = runner.invoke(cli, ["run", "--spec", str(spec_path)])
434
+ assert result.exit_code == 0, result.output
435
+
436
+ # Validation : 2 docs × 3 pipelines × 1 vue = 6 ViewResults.
437
+ results_dir = out_dir / "results"
438
+ view_lines = [
439
+ json.loads(line)
440
+ for line in (results_dir / "view_results.jsonl").read_text().strip().split("\n")
441
+ if line.strip()
442
+ ]
443
+ assert len(view_lines) == 6
444
+
445
+ # Tesseract → CER 0 (copie exacte).
446
+ # GPT-4v / Pero → CER > 0.
447
+ cer_by_pipeline: dict[str, list[float]] = {}
448
+ for vr in view_lines:
449
+ cand_id = vr["candidate_artifact_id"]
450
+ if "precomputed_tesseract" in cand_id:
451
+ pipeline = "tesseract"
452
+ elif "precomputed_gpt4v" in cand_id:
453
+ pipeline = "gpt4v"
454
+ elif "precomputed_pero" in cand_id:
455
+ pipeline = "pero"
456
+ else:
457
+ pytest.fail(f"candidate id inattendu : {cand_id}")
458
+ cer_by_pipeline.setdefault(pipeline, []).append(
459
+ vr["metric_values"]["cer"],
460
+ )
461
+
462
+ # Tesseract = 0 sur les 2 docs.
463
+ assert cer_by_pipeline["tesseract"] == [0.0, 0.0]
464
+ # GPT-4v > 0 (1 erreur).
465
+ for cer in cer_by_pipeline["gpt4v"]:
466
+ assert cer > 0.0
467
+ # Pero strictement plus mauvais que GPT-4v.
468
+ for tess, gpt, pero in zip(
469
+ cer_by_pipeline["tesseract"],
470
+ cer_by_pipeline["gpt4v"],
471
+ cer_by_pipeline["pero"],
472
+ ):
473
+ assert pero > gpt > tess
474
+
475
+ # Le rapport HTML mentionne les 3 pipelines.
476
+ html = report_path.read_text(encoding="utf-8")
477
+ for name in ("tesseract_baseline", "gpt4v_alternative", "pero_alternative"):
478
+ assert name in html
479
+
480
+ def test_missing_source_file_produces_failed_step(
481
+ self, tmp_path: Path,
482
+ ) -> None:
483
+ """Si un fichier pré-calculé manque, le pipeline du document
484
+ concerné échoue (StepResult.error renseigné), mais les autres
485
+ pipelines/documents continuent — le benchmark ne crash pas
486
+ globalement."""
487
+ runner = CliRunner()
488
+ # Corpus avec 1 doc, mais le fichier .tesseract.txt manque.
489
+ buf = io.BytesIO()
490
+ with zipfile.ZipFile(buf, mode="w") as zf:
491
+ zf.writestr("doc01.png", _png_bytes())
492
+ zf.writestr("doc01.gt.txt", "Bonjour")
493
+ # PAS de doc01.tesseract.txt
494
+ corpus_zip = tmp_path / "corpus.zip"
495
+ corpus_zip.write_bytes(buf.getvalue())
496
+
497
+ spec_path = tmp_path / "run.yaml"
498
+ out_dir = tmp_path / "out"
499
+ spec_path.write_text(textwrap.dedent(f"""
500
+ corpus_zip: {corpus_zip}
501
+ corpus_name: bnf_missing
502
+ pipelines:
503
+ - name: tesseract_baseline
504
+ initial_inputs: [image]
505
+ steps:
506
+ - id: ocr
507
+ adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
508
+ adapter_kwargs:
509
+ source_label: tesseract
510
+ input_types: [image]
511
+ output_types: [raw_text]
512
+ views: [text_final]
513
+ output_dir: {out_dir}
514
+ """))
515
+
516
+ result = runner.invoke(cli, ["run", "--spec", str(spec_path)])
517
+ # Le run termine — l'erreur est isolée au step.
518
+ assert result.exit_code == 0, result.output
519
+
520
+ # Le PipelineResult reflète l'échec.
521
+ results_dir = out_dir / "results"
522
+ pipeline_lines = [
523
+ json.loads(line)
524
+ for line in (results_dir / "pipeline_results.jsonl").read_text().strip().split("\n")
525
+ if line.strip()
526
+ ]
527
+ assert len(pipeline_lines) == 1
528
+ pr = pipeline_lines[0]
529
+ assert pr["succeeded"] is False
530
+ assert any(
531
+ sr.get("error") and "introuvable" in sr["error"]
532
+ for sr in pr["step_results"]
533
+ )