Spaces:
Running
sprint33: Phase 0.2 — interface module générique (BaseModule)
Browse filesDeuxiè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 +19 -2
- CLAUDE.md +2 -1
- picarones/core/modules.py +173 -0
- picarones/engines/base.py +36 -3
- tests/test_sprint33_module_interface.py +266 -0
|
@@ -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 →
|
| 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 |
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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"]
|
|
@@ -4,10 +4,12 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import hashlib
|
| 6 |
import time
|
| 7 |
-
from abc import
|
| 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(
|
| 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)
|
|
@@ -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
|