Claude commited on
Commit
52be96b
·
unverified ·
1 Parent(s): f41e382

sprint33: Phase 0.2 — interface module générique (BaseModule)

Browse files

Deuxième sprint du plan d'évolution 2026. Pose l'abstraction qui rend
n'importe quel module de pipeline (OCR, reconstructeur ALTO, rewriter,
mappeur VLM→ALTO, NER, etc.) exécutable par le même runner avec des
types d'I/O déclarés explicitement.

Nouveau module picarones/core/modules.py :
- enum ArtifactType (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) —
valeurs string alignées sur GTLevel pour conversion triviale
- classe abstraite BaseModule avec input_types/output_types déclaratifs,
execution_mode (io/cpu), process(dict[ArtifactType, Any]) typée, et
helpers validate_inputs/validate_outputs

BaseOCREngine (picarones/engines/base.py) hérite désormais de BaseModule
avec input_types=(IMAGE,), output_types=(TEXT,). Sa nouvelle méthode
process wrappe l'API historique run() — aucun adaptateur OCR existant
(Tesseract, Pero, Mistral OCR, Google Vision, Azure DI) n'est touché.
test_engines.py passe à 20/20 sans modification.

Tests : +23 dans test_sprint33_module_interface.py couvrant le contrat
(instanciation, validation I/O, repr), un TextToAltoMock démonstratif
(critère explicite du plan), la délégation BaseOCREngine.process → run,
et la cohérence ArtifactType/GTLevel.
Suite complète : 1495 → 1518 passed, 2 skipped, 0 failed.

Verrou levé : le runner peut maintenant composer des modules de types
d'I/O hétérogènes — fondation directe pour l'axe B (banc d'essai
pipelines BnF) et plusieurs métriques de l'axe A (Layout F1, NER,
reading order F1).

CHANGELOG.md CHANGED
@@ -16,6 +16,24 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 32 — Phase 0.1 : modèle de données GT multi-niveaux.**
20
  Refonte de `picarones/core/corpus.py` :
21
  - Enum `GTLevel` (TEXT, ALTO, PAGE, ENTITIES, READING_ORDER)
@@ -38,8 +56,7 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
38
 
39
  ### Tests
40
 
41
- - 1478 → 1495 tests (+17 sur le Sprint 32). Aucune régression sur la
42
- suite existante.
43
 
44
  ---
45
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 33 — Phase 0.2 : interface module générique.** Création de
20
+ `picarones/core/modules.py` :
21
+ - Enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) —
22
+ valeurs string alignées sur `GTLevel` pour conversion triviale
23
+ - Classe abstraite `BaseModule` avec `input_types`/`output_types`
24
+ déclaratifs, `execution_mode: "io"|"cpu"`, méthode `process` typée
25
+ `dict[ArtifactType, Any] → dict[ArtifactType, Any]`, helpers
26
+ `validate_inputs`/`validate_outputs`, `metadata()` libre
27
+ - `BaseOCREngine` hérite désormais de `BaseModule` avec
28
+ `input_types=(IMAGE,)`, `output_types=(TEXT,)`. Sa nouvelle méthode
29
+ `process` wrappe l'API historique `run()`. Aucun adaptateur OCR
30
+ existant (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI) n'est
31
+ touché — le test_engines.py passe sans modification.
32
+ - +23 tests dans `tests/test_sprint33_module_interface.py` couvrant le
33
+ contrat (instanciation, validation I/O, repr), un `TextToAltoMock`
34
+ démonstratif (TEXT→ALTO, critère explicite du plan), la délégation
35
+ `BaseOCREngine.process → run`, et la cohérence ArtifactType/GTLevel.
36
+
37
  - **Sprint 32 — Phase 0.1 : modèle de données GT multi-niveaux.**
38
  Refonte de `picarones/core/corpus.py` :
39
  - Enum `GTLevel` (TEXT, ALTO, PAGE, ENTITIES, READING_ORDER)
 
56
 
57
  ### Tests
58
 
59
+ - 1478 → 1518 tests (+17 Sprint 32, +23 Sprint 33). Aucune régression.
 
60
 
61
  ---
62
 
CLAUDE.md CHANGED
@@ -204,6 +204,7 @@ AZURE_DOC_INTEL_KEY=...
204
  | 22 | **Sprint 7 du plan rapport (clôture phase 0)** : études de cas, documentation utilisateur, documentation développeur. Création de `docs/case-studies/` avec 2 cas d'école explicitement étiquetés (registres paroissiaux XVIIᵉ-XVIIIᵉ pour archivistes ; édition critique d'un manuscrit médiéval pour philologues). Encart sous la synthèse pointant vers le dossier. Documentation utilisateur `docs/user/reading-a-report.md` (anatomie du rapport, ordre de lecture suggéré, panneau avancé). Trois guides développeur (`docs/developer/index.md`, `narrative-engine.md`, `extending-glossary.md`, `extending-i18n.md`) couvrant l'extension de chaque sous-système. Tests E2E sur petits/grands corpus + locale EN, garde-fou « pas de fausses études prétendant être réelles » (chaque .md case-study doit contenir « Cas d'école »). 18 tests Sprint 22. |
205
  | 23-31 | Sprints intermédiaires : anti-hallucination, sécurité institutionnelle, refactor frontend Jinja2, persistance SQLite des jobs, snapshots reproductibilité, save/load config + comparaison de runs, registre déclaratif des détecteurs, polish/a11y/DX, couverture des modules sous-testés. Voir `CHANGELOG.md` [1.1.x] pour le détail. |
206
  | 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
 
207
 
208
  ---
209
 
@@ -250,7 +251,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
250
  ## Contexte développement
251
 
252
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
253
- - **Tests** : 1495 passed, 2 skipped (Sprint 32 — Phase 0.1 du plan d'évolution 2026 terminée)
254
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
255
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
256
  - **Transcript de la conversation de développement** :
 
204
  | 22 | **Sprint 7 du plan rapport (clôture phase 0)** : études de cas, documentation utilisateur, documentation développeur. Création de `docs/case-studies/` avec 2 cas d'école explicitement étiquetés (registres paroissiaux XVIIᵉ-XVIIIᵉ pour archivistes ; édition critique d'un manuscrit médiéval pour philologues). Encart sous la synthèse pointant vers le dossier. Documentation utilisateur `docs/user/reading-a-report.md` (anatomie du rapport, ordre de lecture suggéré, panneau avancé). Trois guides développeur (`docs/developer/index.md`, `narrative-engine.md`, `extending-glossary.md`, `extending-i18n.md`) couvrant l'extension de chaque sous-système. Tests E2E sur petits/grands corpus + locale EN, garde-fou « pas de fausses études prétendant être réelles » (chaque .md case-study doit contenir « Cas d'école »). 18 tests Sprint 22. |
205
  | 23-31 | Sprints intermédiaires : anti-hallucination, sécurité institutionnelle, refactor frontend Jinja2, persistance SQLite des jobs, snapshots reproductibilité, save/load config + comparaison de runs, registre déclaratif des détecteurs, polish/a11y/DX, couverture des modules sous-testés. Voir `CHANGELOG.md` [1.1.x] pour le détail. |
206
  | 32 | **Sprint 1 du plan d'évolution 2026 — Phase 0.1 : GT multi-niveaux**. Refonte de `picarones/core/corpus.py` pour porter une vérité terrain à plusieurs niveaux (`GTLevel.{TEXT,ALTO,PAGE,ENTITIES,READING_ORDER}`), payloads typés (`TextGT`, `AltoGT`, `PageGT`, `EntitiesGT`, `ReadingOrderGT`) avec `source_path` traçable. Le champ `Document.ground_truth: str` reste la source de vérité historique et est synchronisé automatiquement avec `Document.ground_truths[GTLevel.TEXT]` — rétrocompatibilité stricte (1478 tests existants passent sans modification). Le loader détecte automatiquement `.gt.alto.xml`, `.gt.page.xml`, `.gt.entities.json`, `.gt.reading_order.json` à côté de l'image. `Corpus.gt_level_coverage()` et `Corpus.available_gt_levels` exposent la couverture. Erreurs de parse dégradées en `logger.warning` (jamais `except: pass`). +17 tests dans `test_sprint32_multi_level_gt.py`. **Verrou levé** : ce sprint débloque l'évaluation des modules qui produisent ou consomment ALTO/PAGE/entités (axe B du plan, à venir Sprint 35+) et plusieurs métriques de l'axe A (Layout F1, reading order F1, NER). |
207
+ | 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
208
 
209
  ---
210
 
 
251
  ## Contexte développement
252
 
253
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
254
+ - **Tests** : 1518 passed, 2 skipped (Sprints 32-33 — Phase 0.1 + 0.2 du plan d'évolution 2026)
255
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
256
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
257
  - **Transcript de la conversation de développement** :
picarones/core/modules.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interface module générique (Sprint 33 — Phase 0.2 du plan d'évolution).
2
+
3
+ Pourquoi ce module
4
+ ------------------
5
+ Aujourd'hui ``BaseOCREngine`` (`picarones/engines/base.py`) est typé
6
+ implicitement « image → texte » par sa signature. Cette assomption
7
+ empêche d'évaluer dans le même runner :
8
+
9
+ - un mappeur VLM → ALTO (image → texte + ALTO),
10
+ - un rewriter ALTO post-correction (ALTO → ALTO),
11
+ - un module NER (texte → entités),
12
+ - un reconstructeur de structure (image + texte → ALTO).
13
+
14
+ ``BaseModule`` est l'interface générique dont ``BaseOCREngine`` devient
15
+ un cas particulier. Un module déclare explicitement les types
16
+ d'artefacts qu'il **consomme** (``input_types``) et qu'il **produit**
17
+ (``output_types``). Le runner peut alors composer plusieurs modules en
18
+ une pipeline (cf. axe B du plan d'évolution).
19
+
20
+ Rétrocompatibilité
21
+ ------------------
22
+ Aucun adaptateur OCR existant n'est touché par ce sprint. La méthode
23
+ ``BaseModule.process`` est implémentée par défaut sur ``BaseOCREngine``
24
+ de manière à wrapper l'ancien ``_run_ocr`` — toutes les sous-classes
25
+ historiques (Tesseract, Pero OCR, Mistral OCR, Google Vision,
26
+ Azure Document Intelligence) continuent à fonctionner sans modification.
27
+
28
+ Convention sur ``ArtifactType``
29
+ -------------------------------
30
+ Les valeurs string de ``ArtifactType`` sont volontairement les mêmes que
31
+ celles de ``GTLevel`` (Sprint 32) sauf pour ``IMAGE`` qui n'a pas de
32
+ correspondance GT. La conversion entre les deux se fait trivialement
33
+ via ``.value`` :
34
+
35
+ >>> from picarones.core.corpus import GTLevel
36
+ >>> from picarones.core.modules import ArtifactType
37
+ >>> ArtifactType(GTLevel.TEXT.value) is ArtifactType.TEXT
38
+ True
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ from abc import ABC, abstractmethod
44
+ from enum import Enum
45
+ from typing import Any, Literal
46
+
47
+
48
+ class ArtifactType(str, Enum):
49
+ """Type d'artefact transitant entre modules d'une pipeline.
50
+
51
+ Inclut le ``IMAGE`` (entrée typique d'un OCR) et tous les niveaux
52
+ de ``GTLevel`` (Sprint 32) qui peuvent être produits ou consommés
53
+ par un module.
54
+ """
55
+
56
+ IMAGE = "image"
57
+ TEXT = "text"
58
+ ALTO = "alto"
59
+ PAGE = "page"
60
+ ENTITIES = "entities"
61
+ READING_ORDER = "reading_order"
62
+
63
+
64
+ ExecutionMode = Literal["io", "cpu"]
65
+
66
+
67
+ class BaseModule(ABC):
68
+ """Interface générique pour tout module exécutable par le runner.
69
+
70
+ Un module est une fonction typée d'artefacts vers artefacts. Il
71
+ déclare ce qu'il consomme et ce qu'il produit, et expose une méthode
72
+ ``process`` qui prend un dictionnaire d'entrées et retourne un
73
+ dictionnaire de sorties.
74
+
75
+ Attributs de classe (à surcharger en sous-classe)
76
+ -------------------------------------------------
77
+ input_types : tuple[ArtifactType, ...]
78
+ Types d'artefacts consommés par ``process``. L'ordre n'a pas de
79
+ signification ; le runner passe un dictionnaire.
80
+ output_types : tuple[ArtifactType, ...]
81
+ Types d'artefacts produits par ``process``. Tous les types
82
+ listés doivent être présents dans le dict retourné par
83
+ ``process`` (le runner valide).
84
+ execution_mode : ``"io"`` ou ``"cpu"``
85
+ Indique au runner quel exécuteur utiliser : ``ThreadPoolExecutor``
86
+ pour les modules I/O-bound (API, réseau), ``ProcessPoolExecutor``
87
+ pour les CPU-bound (Tesseract, Pero).
88
+
89
+ Exemple minimal
90
+ ---------------
91
+ >>> class UpperCaseModule(BaseModule):
92
+ ... input_types = (ArtifactType.TEXT,)
93
+ ... output_types = (ArtifactType.TEXT,)
94
+ ... execution_mode = "cpu"
95
+ ...
96
+ ... @property
97
+ ... def name(self) -> str:
98
+ ... return "uppercase"
99
+ ...
100
+ ... def process(self, inputs):
101
+ ... return {ArtifactType.TEXT: inputs[ArtifactType.TEXT].upper()}
102
+ >>> m = UpperCaseModule()
103
+ >>> m.process({ArtifactType.TEXT: "hello"})
104
+ {<ArtifactType.TEXT: 'text'>: 'HELLO'}
105
+ """
106
+
107
+ input_types: tuple[ArtifactType, ...] = ()
108
+ output_types: tuple[ArtifactType, ...] = ()
109
+ execution_mode: ExecutionMode = "io"
110
+
111
+ @property
112
+ @abstractmethod
113
+ def name(self) -> str:
114
+ """Identifiant unique et stable du module."""
115
+
116
+ @abstractmethod
117
+ def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
118
+ """Exécute le module sur les artefacts d'entrée.
119
+
120
+ Parameters
121
+ ----------
122
+ inputs:
123
+ Dictionnaire ``{ArtifactType: payload}``. Tous les types
124
+ déclarés dans ``input_types`` doivent être présents
125
+ (``validate_inputs`` peut être utilisé pour valider).
126
+
127
+ Returns
128
+ -------
129
+ dict[ArtifactType, Any]
130
+ Dictionnaire des sorties produites. Tous les types déclarés
131
+ dans ``output_types`` doivent être présents.
132
+ """
133
+
134
+ def metadata(self) -> dict:
135
+ """Métadonnées libres exposées par le module.
136
+
137
+ Sous-classes peuvent surcharger pour exposer la version, la
138
+ license, la citation académique, etc. Le runner inclut ce dict
139
+ dans le résultat afin que le rapport puisse l'afficher.
140
+ """
141
+ return {}
142
+
143
+ # ──────────────────────────────────────────────────────────────────
144
+ # Helpers de validation utilisés par le runner et les tests
145
+ # ──────────────────────────────────────────────────────────────────
146
+
147
+ def validate_inputs(self, inputs: dict[ArtifactType, Any]) -> None:
148
+ """Lève ``ValueError`` si un type d'entrée déclaré est manquant."""
149
+ missing = [t for t in self.input_types if t not in inputs]
150
+ if missing:
151
+ raise ValueError(
152
+ f"Module {self.name!r} : entrées manquantes "
153
+ f"{[t.value for t in missing]} (attendues : "
154
+ f"{[t.value for t in self.input_types]})"
155
+ )
156
+
157
+ def validate_outputs(self, outputs: dict[ArtifactType, Any]) -> None:
158
+ """Lève ``ValueError`` si un type de sortie déclaré est manquant."""
159
+ missing = [t for t in self.output_types if t not in outputs]
160
+ if missing:
161
+ raise ValueError(
162
+ f"Module {self.name!r} : sorties manquantes "
163
+ f"{[t.value for t in missing]} (déclarées : "
164
+ f"{[t.value for t in self.output_types]})"
165
+ )
166
+
167
+ def __repr__(self) -> str:
168
+ ins = ",".join(t.value for t in self.input_types) or "·"
169
+ outs = ",".join(t.value for t in self.output_types) or "·"
170
+ return f"{self.__class__.__name__}(name={self.name!r}, {ins}→{outs})"
171
+
172
+
173
+ __all__ = ["ArtifactType", "BaseModule", "ExecutionMode"]
picarones/engines/base.py CHANGED
@@ -4,10 +4,12 @@ from __future__ import annotations
4
 
5
  import hashlib
6
  import time
7
- from abc import ABC, abstractmethod
8
  from dataclasses import dataclass, field
9
  from pathlib import Path
10
- from typing import Optional
 
 
11
 
12
 
13
  @dataclass
@@ -30,9 +32,16 @@ class EngineResult:
30
  return hashlib.sha256(Path(self.image_path).read_bytes()).hexdigest()
31
 
32
 
33
- class BaseOCREngine(ABC):
34
  """Classe de base dont héritent tous les adaptateurs OCR.
35
 
 
 
 
 
 
 
 
36
  Chaque adaptateur doit implémenter :
37
  - ``name`` : identifiant unique du moteur
38
  - ``version()`` : retourne la version du moteur sous forme de chaîne
@@ -46,6 +55,9 @@ class BaseOCREngine(ABC):
46
  - ``"cpu"`` → ``ProcessPoolExecutor`` (moteurs CPU-intensifs : Tesseract, Pero, Kraken)
47
  """
48
 
 
 
 
49
  execution_mode: str = "io"
50
  """``"io"`` pour ThreadPoolExecutor (défaut), ``"cpu"`` pour ProcessPoolExecutor."""
51
 
@@ -65,6 +77,27 @@ class BaseOCREngine(ABC):
65
  def _run_ocr(self, image_path: Path) -> str:
66
  """Exécute l'OCR et retourne le texte brut extrait."""
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def run(self, image_path: str | Path) -> EngineResult:
69
  """Point d'entrée public : exécute l'OCR et mesure le temps d'exécution."""
70
  image_path = Path(image_path)
 
4
 
5
  import hashlib
6
  import time
7
+ from abc import abstractmethod
8
  from dataclasses import dataclass, field
9
  from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from picarones.core.modules import ArtifactType, BaseModule
13
 
14
 
15
  @dataclass
 
32
  return hashlib.sha256(Path(self.image_path).read_bytes()).hexdigest()
33
 
34
 
35
+ class BaseOCREngine(BaseModule):
36
  """Classe de base dont héritent tous les adaptateurs OCR.
37
 
38
+ Sprint 33 — Phase 0.2 : ``BaseOCREngine`` hérite désormais de
39
+ ``BaseModule`` (cf. ``picarones.core.modules``) afin que les moteurs
40
+ OCR existants soient automatiquement utilisables comme nœuds d'une
41
+ pipeline composée (axe B du plan d'évolution). Aucune sous-classe
42
+ OCR n'est touchée : la méthode ``process`` est implémentée ici et
43
+ délègue à ``run`` puis à ``_run_ocr``.
44
+
45
  Chaque adaptateur doit implémenter :
46
  - ``name`` : identifiant unique du moteur
47
  - ``version()`` : retourne la version du moteur sous forme de chaîne
 
55
  - ``"cpu"`` → ``ProcessPoolExecutor`` (moteurs CPU-intensifs : Tesseract, Pero, Kraken)
56
  """
57
 
58
+ # Déclaration BaseModule — un OCR consomme une image et produit du texte.
59
+ input_types = (ArtifactType.IMAGE,)
60
+ output_types = (ArtifactType.TEXT,)
61
  execution_mode: str = "io"
62
  """``"io"`` pour ThreadPoolExecutor (défaut), ``"cpu"`` pour ProcessPoolExecutor."""
63
 
 
77
  def _run_ocr(self, image_path: Path) -> str:
78
  """Exécute l'OCR et retourne le texte brut extrait."""
79
 
80
+ # ──────────────────────────────────────────────────────────────────
81
+ # Implémentation BaseModule (Sprint 33)
82
+ # ──────────────────────────────────────────────────────────────────
83
+
84
+ def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
85
+ """Exécute le moteur OCR comme un module générique.
86
+
87
+ Wrapper rétrocompatible : extrait le chemin image de ``inputs``,
88
+ appelle ``run()``, et retourne la sortie sous forme de dictionnaire
89
+ ``{ArtifactType.TEXT: text}``. Les erreurs sont conservées dans
90
+ le résultat (cf. ``EngineResult.error``) plutôt que de lever, comme
91
+ l'implémentation historique de ``run()``.
92
+ """
93
+ self.validate_inputs(inputs)
94
+ result = self.run(inputs[ArtifactType.IMAGE])
95
+ return {ArtifactType.TEXT: result.text}
96
+
97
+ def metadata(self) -> dict:
98
+ """Expose la version du moteur dans les métadonnées du module."""
99
+ return {"engine_version": self._safe_version()}
100
+
101
  def run(self, image_path: str | Path) -> EngineResult:
102
  """Point d'entrée public : exécute l'OCR et mesure le temps d'exécution."""
103
  image_path = Path(image_path)
tests/test_sprint33_module_interface.py ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 33 — Interface module générique (Phase 0.2).
2
+
3
+ Vérifie :
4
+
5
+ 1. ``BaseModule`` est instanciable via une sous-classe minimale qui
6
+ déclare ses ``input_types`` / ``output_types`` et implémente
7
+ ``process``.
8
+ 2. La validation des entrées/sorties (``validate_inputs`` /
9
+ ``validate_outputs``) lève ``ValueError`` quand un type déclaré est
10
+ manquant.
11
+ 3. Un ``MockModule`` qui consomme ``TEXT`` et produit ``ALTO`` peut
12
+ exister — l'interface n'est pas restreinte aux OCR (critère
13
+ explicite du plan).
14
+ 4. ``BaseOCREngine`` hérite de ``BaseModule`` et expose
15
+ ``input_types=(IMAGE,)``, ``output_types=(TEXT,)``.
16
+ 5. La méthode ``process`` d'un moteur OCR existant délègue correctement
17
+ à ``run``/``_run_ocr`` et retourne le bon type d'artefact.
18
+ 6. Les valeurs string de ``ArtifactType`` correspondent à celles de
19
+ ``GTLevel`` pour permettre la conversion triviale.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import pytest
28
+
29
+ from picarones.core.corpus import GTLevel
30
+ from picarones.core.modules import ArtifactType, BaseModule
31
+ from picarones.engines.base import BaseOCREngine, EngineResult
32
+
33
+
34
+ # ──────────────────────────────────────────────────────────────────────────
35
+ # Fixtures de modules de test
36
+ # ──────────────────────────────────────────────────────────────────────────
37
+
38
+
39
+ class UpperCaseTextModule(BaseModule):
40
+ """Module trivial TEXT → TEXT pour valider le contrat de base."""
41
+
42
+ input_types = (ArtifactType.TEXT,)
43
+ output_types = (ArtifactType.TEXT,)
44
+ execution_mode = "cpu"
45
+
46
+ @property
47
+ def name(self) -> str:
48
+ return "uppercase"
49
+
50
+ def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
51
+ self.validate_inputs(inputs)
52
+ return {ArtifactType.TEXT: inputs[ArtifactType.TEXT].upper()}
53
+
54
+
55
+ class TextToAltoMock(BaseModule):
56
+ """Mock TEXT → ALTO : le critère de réussite explicite du plan.
57
+
58
+ Un cas d'école pour le futur ``alto_reconstructor`` BnF (cf. plan
59
+ d'évolution, Sprint B.1).
60
+ """
61
+
62
+ input_types = (ArtifactType.TEXT,)
63
+ output_types = (ArtifactType.ALTO,)
64
+ execution_mode = "cpu"
65
+
66
+ @property
67
+ def name(self) -> str:
68
+ return "text_to_alto_mock"
69
+
70
+ def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
71
+ self.validate_inputs(inputs)
72
+ text = inputs[ArtifactType.TEXT]
73
+ # Génère un ALTO trivial qui contient le texte en CONTENT
74
+ alto = (
75
+ '<?xml version="1.0" encoding="UTF-8"?>'
76
+ '<alto xmlns="http://www.loc.gov/standards/alto/ns-v4#">'
77
+ f'<Layout><Page><PrintSpace><TextBlock><TextLine>'
78
+ f'<String CONTENT="{text}"/>'
79
+ f'</TextLine></TextBlock></PrintSpace></Page></Layout>'
80
+ '</alto>'
81
+ )
82
+ return {ArtifactType.ALTO: alto}
83
+
84
+ def metadata(self) -> dict:
85
+ return {"strategy": "trivial_single_string"}
86
+
87
+
88
+ class FaultyModule(BaseModule):
89
+ """Module qui prétend produire ALTO mais ne le fait pas — pour tester
90
+ la validation des sorties."""
91
+
92
+ input_types = (ArtifactType.TEXT,)
93
+ output_types = (ArtifactType.ALTO,)
94
+
95
+ @property
96
+ def name(self) -> str:
97
+ return "faulty"
98
+
99
+ def process(self, inputs: dict[ArtifactType, Any]) -> dict[ArtifactType, Any]:
100
+ return {ArtifactType.TEXT: "oops"} # mauvais type de sortie
101
+
102
+
103
+ class FakeOCREngine(BaseOCREngine):
104
+ """Moteur OCR factice pour tester la délégation BaseOCREngine.process."""
105
+
106
+ @property
107
+ def name(self) -> str:
108
+ return "fake_ocr"
109
+
110
+ def version(self) -> str:
111
+ return "0.1.0"
112
+
113
+ def _run_ocr(self, image_path: Path) -> str:
114
+ return f"transcription de {image_path.name}"
115
+
116
+
117
+ # ──────────────────────────────────────────────────────────────────────────
118
+ # 1 & 2. Contrat BaseModule : instanciation et validation
119
+ # ──────────────────────────────────────────────────────────────────────────
120
+
121
+
122
+ class TestBaseModuleContract:
123
+ def test_minimal_module_runs(self) -> None:
124
+ m = UpperCaseTextModule()
125
+ out = m.process({ArtifactType.TEXT: "bonjour"})
126
+ assert out == {ArtifactType.TEXT: "BONJOUR"}
127
+
128
+ def test_validate_inputs_missing_raises(self) -> None:
129
+ m = UpperCaseTextModule()
130
+ with pytest.raises(ValueError, match="entrées manquantes"):
131
+ m.validate_inputs({})
132
+
133
+ def test_validate_outputs_missing_raises(self) -> None:
134
+ m = UpperCaseTextModule()
135
+ with pytest.raises(ValueError, match="sorties manquantes"):
136
+ m.validate_outputs({})
137
+
138
+ def test_validate_outputs_passes_when_complete(self) -> None:
139
+ m = UpperCaseTextModule()
140
+ # Doit passer sans lever
141
+ m.validate_outputs({ArtifactType.TEXT: "hello"})
142
+
143
+ def test_default_metadata_is_empty(self) -> None:
144
+ assert UpperCaseTextModule().metadata() == {}
145
+
146
+ def test_repr_shows_io_types(self) -> None:
147
+ m = UpperCaseTextModule()
148
+ r = repr(m)
149
+ assert "uppercase" in r
150
+ assert "text→text" in r
151
+
152
+ def test_default_execution_mode(self) -> None:
153
+ # UpperCaseTextModule a forcé "cpu" ; un module qui ne déclare
154
+ # rien hérite de "io".
155
+ class IOModule(BaseModule):
156
+ input_types = (ArtifactType.TEXT,)
157
+ output_types = (ArtifactType.TEXT,)
158
+
159
+ @property
160
+ def name(self) -> str:
161
+ return "io"
162
+
163
+ def process(self, inputs):
164
+ return {ArtifactType.TEXT: inputs[ArtifactType.TEXT]}
165
+
166
+ assert IOModule.execution_mode == "io"
167
+
168
+
169
+ # ──────────────────────────────────────────────────────────────────────────
170
+ # 3. MockModule TEXT → ALTO (critère explicite du plan)
171
+ # ──────────────────────────────────────────────────────────────────────────
172
+
173
+
174
+ class TestMockTextToAlto:
175
+ def test_text_to_alto_runs(self) -> None:
176
+ m = TextToAltoMock()
177
+ out = m.process({ArtifactType.TEXT: "Hello"})
178
+
179
+ assert ArtifactType.ALTO in out
180
+ assert "Hello" in out[ArtifactType.ALTO]
181
+ assert "alto" in out[ArtifactType.ALTO]
182
+
183
+ def test_text_to_alto_declares_correct_types(self) -> None:
184
+ assert TextToAltoMock.input_types == (ArtifactType.TEXT,)
185
+ assert TextToAltoMock.output_types == (ArtifactType.ALTO,)
186
+
187
+ def test_text_to_alto_metadata_exposed(self) -> None:
188
+ assert TextToAltoMock().metadata() == {"strategy": "trivial_single_string"}
189
+
190
+ def test_validate_inputs_catches_missing_text(self) -> None:
191
+ m = TextToAltoMock()
192
+ with pytest.raises(ValueError):
193
+ # Donne une IMAGE alors qu'on attend TEXT
194
+ m.process({ArtifactType.IMAGE: Path("/tmp/x.png")})
195
+
196
+
197
+ # ──────────────────────────────────────────────────────────────────────────
198
+ # 4 & 5. BaseOCREngine est rétrocompatible et respecte BaseModule
199
+ # ──────────────────────────────────────────────────────────────────────────
200
+
201
+
202
+ class TestOCREngineAsModule:
203
+ def test_baseocrengine_is_basemodule(self) -> None:
204
+ assert issubclass(BaseOCREngine, BaseModule)
205
+
206
+ def test_baseocrengine_io_types(self) -> None:
207
+ assert BaseOCREngine.input_types == (ArtifactType.IMAGE,)
208
+ assert BaseOCREngine.output_types == (ArtifactType.TEXT,)
209
+
210
+ def test_fake_engine_run_unchanged(self, tmp_path: Path) -> None:
211
+ """L'API historique ``run`` retourne un ``EngineResult`` intact."""
212
+ image = tmp_path / "doc.png"
213
+ image.write_bytes(b"\x89PNG")
214
+ engine = FakeOCREngine()
215
+
216
+ result = engine.run(image)
217
+
218
+ assert isinstance(result, EngineResult)
219
+ assert result.success
220
+ assert result.text == "transcription de doc.png"
221
+ assert result.engine_name == "fake_ocr"
222
+
223
+ def test_fake_engine_process_returns_text_artifact(self, tmp_path: Path) -> None:
224
+ """``process`` délègue à ``run`` et retourne ``{TEXT: ...}``."""
225
+ image = tmp_path / "doc.png"
226
+ image.write_bytes(b"\x89PNG")
227
+ engine = FakeOCREngine()
228
+
229
+ outputs = engine.process({ArtifactType.IMAGE: image})
230
+
231
+ assert outputs == {ArtifactType.TEXT: "transcription de doc.png"}
232
+
233
+ def test_fake_engine_process_validates_missing_image(self) -> None:
234
+ engine = FakeOCREngine()
235
+ with pytest.raises(ValueError, match="entrées manquantes"):
236
+ engine.process({ArtifactType.TEXT: "wrong artifact"})
237
+
238
+ def test_fake_engine_metadata_exposes_version(self) -> None:
239
+ meta = FakeOCREngine().metadata()
240
+ assert meta == {"engine_version": "0.1.0"}
241
+
242
+
243
+ # ──────────────────────────────────────────────────────────────────────────
244
+ # 6. Cohérence ArtifactType / GTLevel
245
+ # ───────��──────────────────────────────────────────────────────────────────
246
+
247
+
248
+ class TestArtifactTypeGTLevelCoherence:
249
+ @pytest.mark.parametrize(
250
+ "level",
251
+ [
252
+ GTLevel.TEXT,
253
+ GTLevel.ALTO,
254
+ GTLevel.PAGE,
255
+ GTLevel.ENTITIES,
256
+ GTLevel.READING_ORDER,
257
+ ],
258
+ )
259
+ def test_each_gtlevel_maps_to_artifacttype(self, level: GTLevel) -> None:
260
+ """La conversion ``GTLevel → ArtifactType`` doit être triviale."""
261
+ assert ArtifactType(level.value) is not None
262
+
263
+ def test_image_has_no_gtlevel_counterpart(self) -> None:
264
+ """``IMAGE`` n'est pas une GT, c'est cohérent avec le plan."""
265
+ gt_values = {lvl.value for lvl in GTLevel}
266
+ assert ArtifactType.IMAGE.value not in gt_values