cinoc-deploy commited on
Commit
600b32c
·
0 Parent(s):

Déploiement de la vitrine Cinoc

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +8 -0
  2. Dockerfile +90 -0
  3. README.md +52 -0
  4. cinoc/_version.py +24 -0
  5. cinoc/adapters/ANALYSE_COUCHE_5.md +271 -0
  6. cinoc/adapters/__init__.py +9 -0
  7. cinoc/adapters/_resilience.py +170 -0
  8. cinoc/adapters/_workspace.py +31 -0
  9. cinoc/adapters/corpus/__init__.py +39 -0
  10. cinoc/adapters/corpus/_http.py +408 -0
  11. cinoc/adapters/corpus/escriptorium.py +185 -0
  12. cinoc/adapters/corpus/gallica.py +160 -0
  13. cinoc/adapters/corpus/htr_united.py +176 -0
  14. cinoc/adapters/corpus/huggingface.py +487 -0
  15. cinoc/adapters/corpus/iiif.py +137 -0
  16. cinoc/adapters/images.py +137 -0
  17. cinoc/adapters/layout/__init__.py +3 -0
  18. cinoc/adapters/layout/_base.py +114 -0
  19. cinoc/adapters/layout/_saknussemm_bridge.py +187 -0
  20. cinoc/adapters/layout/alto_source.py +230 -0
  21. cinoc/adapters/layout/assembler.py +89 -0
  22. cinoc/adapters/layout/crop.py +72 -0
  23. cinoc/adapters/layout/pp_doclayout.py +147 -0
  24. cinoc/adapters/layout/precomputed.py +178 -0
  25. cinoc/adapters/layout/remote.py +194 -0
  26. cinoc/adapters/layout/saknussemm_correct.py +324 -0
  27. cinoc/adapters/layout/to_text.py +106 -0
  28. cinoc/adapters/llm/__init__.py +15 -0
  29. cinoc/adapters/llm/_base.py +285 -0
  30. cinoc/adapters/llm/anthropic.py +202 -0
  31. cinoc/adapters/llm/mistral.py +230 -0
  32. cinoc/adapters/llm/ollama.py +240 -0
  33. cinoc/adapters/llm/ollama_structured.py +120 -0
  34. cinoc/adapters/llm/openai.py +197 -0
  35. cinoc/adapters/ner/__init__.py +1 -0
  36. cinoc/adapters/ner/spacy_extractor.py +204 -0
  37. cinoc/adapters/ocr/__init__.py +8 -0
  38. cinoc/adapters/ocr/_base.py +64 -0
  39. cinoc/adapters/ocr/azure_di.py +223 -0
  40. cinoc/adapters/ocr/calamari.py +106 -0
  41. cinoc/adapters/ocr/google_vision.py +157 -0
  42. cinoc/adapters/ocr/kraken.py +166 -0
  43. cinoc/adapters/ocr/mistral_ocr.py +113 -0
  44. cinoc/adapters/ocr/pero.py +114 -0
  45. cinoc/adapters/ocr/precomputed.py +91 -0
  46. cinoc/adapters/ocr/tesseract.py +332 -0
  47. cinoc/adapters/preprocess/__init__.py +5 -0
  48. cinoc/adapters/preprocess/_ops.py +115 -0
  49. cinoc/adapters/preprocess/image_prep.py +214 -0
  50. cinoc/adapters/storage/__init__.py +24 -0
.gitattributes ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ *.pdf filter=lfs diff=lfs merge=lfs -text
2
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
3
+ *.woff filter=lfs diff=lfs merge=lfs -text
4
+ *.otf filter=lfs diff=lfs merge=lfs -text
5
+ *.ttf filter=lfs diff=lfs merge=lfs -text
6
+ *.png filter=lfs diff=lfs merge=lfs -text
7
+ *.jpg filter=lfs diff=lfs merge=lfs -text
8
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Image **moteur** Cinoc pour un Hugging Face Space (SDK: docker).
2
+ #
3
+ # ≠ la vitrine lecture seule (deploy/Dockerfile{,.space}) : celle-ci **bake un vrai
4
+ # moteur OCR gratuit** (Tesseract 5 + langues fra/lat/eng) pour qu'un visiteur du
5
+ # Space public lance un run Tesseract **sans clé ni installation**. Les moteurs
6
+ # cloud et les plugins tiers restent *gated* (mode public, fail-closed côté appli).
7
+ #
8
+ # Contexte de build « racine plate » (Dockerfile / requirements.txt / pyproject.toml
9
+ # / cinoc / reports TOUS à la racine) — assemblé par .github/workflows/deploy-space.yml,
10
+ # comme Dockerfile.space.
11
+ FROM python:3.11-slim
12
+
13
+ # ── Dépendances système : Tesseract 5 + langues + libs image (Pillow) ──────────
14
+ # Les langues sont baquées (pas téléchargées au runtime) : un Space free-tier n'a
15
+ # pas d'accès réseau garanti au démarrage, et le déterminisme exige une version de
16
+ # binaire figée. ``fra`` est le besoin patrimonial central ; ``lat`` (latin) et
17
+ # ``eng`` complètent. Pas de pin de version exacte (Debian point-release rebump
18
+ # fréquemment ``5.3.0-2`` → ``+deb12u1`` → casse le build) : la repro repose sur la
19
+ # capture de ``tesseract --version`` dans ``RunManifest.system_binaries_lock``.
20
+ RUN apt-get update -o Acquire::Retries=3 \
21
+ && apt-get install -y --no-install-recommends -o Acquire::Retries=3 \
22
+ tesseract-ocr \
23
+ tesseract-ocr-fra \
24
+ tesseract-ocr-lat \
25
+ tesseract-ocr-eng \
26
+ libtiff6 \
27
+ libjpeg62-turbo \
28
+ libpng16-16 \
29
+ libwebp7 \
30
+ && apt-get clean \
31
+ && rm -rf /var/lib/apt/lists/*
32
+
33
+ # ── Variables d'exécution ──────────────────────────────────────────────────────
34
+ # TESSDATA_PREFIX : emplacement Debian bookworm des modèles Tesseract 5.
35
+ ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
36
+ # OMP_THREAD_LIMIT=1 : OBLIGATOIRE sur free-tier 2 vCPU. Tesseract LSTM (oem 3)
37
+ # parallélise via OpenMP ; non borné sur un CPU partagé il **deadlock** (incident
38
+ # documenté côté Picarones, 2026-05-16) — l'OCR gèle et chaque document timeoute.
39
+ # Mono-thread par appel = plus rapide ET déterministe ici.
40
+ ENV OMP_THREAD_LIMIT=1
41
+ # Mode public (verrou *fail-closed*) **non forcé** : par défaut l'instance exécute
42
+ # ses moteurs (cloud compris) avec la clé de l'opérateur — pas de 403. Qui expose
43
+ # un Space **public** et veut protéger sa clé d'un visiteur pose lui-même
44
+ # CINOC_PUBLIC_MODE=true (alors seul le socle gratuit tesseract s'exécute).
45
+ ENV PYTHONUNBUFFERED=1
46
+
47
+ # ── Smoke-test du binaire au BUILD (fail-fast) ─────────────────────────────────
48
+ # Un Tesseract cassé OU figé doit casser le BUILD (le Space garde l'image
49
+ # précédente fonctionnelle), pas déployer une image qui gèle en silence :
50
+ # (a) le binaire charge ses libs et expose la langue ``fra`` (sinon build refusé) ;
51
+ # (b) une reconnaissance réelle sur une image générée se TERMINE sous 30 s — un
52
+ # timeout (rc 124) = deadlock OpenMP / libs incohérentes → build refusé.
53
+ RUN set -eu; \
54
+ timeout 30 tesseract --version; \
55
+ timeout 30 tesseract --list-langs 2>&1 | grep -qx fra; \
56
+ printf 'P1\n16 16\n' > /tmp/smoke.pbm; \
57
+ i=0; while [ "$i" -lt 16 ]; do \
58
+ printf '1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0\n' >> /tmp/smoke.pbm; \
59
+ i=$((i + 1)); \
60
+ done; \
61
+ rc=0; \
62
+ timeout 30 tesseract /tmp/smoke.pbm - -l fra --psm 6 >/dev/null 2>&1 || rc=$?; \
63
+ rm -f /tmp/smoke.pbm; \
64
+ if [ "$rc" = 124 ]; then \
65
+ echo "FATAL: tesseract a gelé (timeout) — build refusé, libs incohérentes." >&2; \
66
+ exit 1; \
67
+ fi
68
+
69
+ # ── Utilisateur non-root (sécurité d'un service exposé) ────────────────────────
70
+ RUN useradd --create-home --uid 1000 cinoc
71
+ WORKDIR /home/cinoc/app
72
+
73
+ # ── Dépendances Python d'abord (cache de couche) ───────────────────────────────
74
+ # requirements.txt fige le cœur + l'extra [serve] + pytesseract (wrapper du binaire).
75
+ COPY requirements.txt ./requirements.txt
76
+ RUN pip install --no-cache-dir -r requirements.txt
77
+
78
+ # ── Code + paquet (sans deps : tout est déjà figé ci-dessus) ───────────────────
79
+ COPY pyproject.toml README.md ./
80
+ COPY cinoc ./cinoc
81
+ RUN pip install --no-cache-dir --no-deps .
82
+
83
+ # ── Rapports bakés (lecture seule) — dossier toujours présent ──────────────────
84
+ COPY reports ./reports
85
+ ENV CINOC_REPORTS_DIR=/home/cinoc/app/reports
86
+
87
+ USER cinoc
88
+ EXPOSE 7860
89
+ # 0.0.0.0 attendu en conteneur (le réseau est géré par la plateforme).
90
+ CMD ["cinoc", "serve", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Cinoc — banc d'essai OCR/HTR/VLM
3
+ emoji: 📜
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ ---
11
+
12
+ # Cinoc — banc d'essai OCR/HTR/VLM
13
+
14
+ Banc d'essai **déterministe** de pipelines de transcription (OCR / HTR / VLM).
15
+ Ce Space **exécute un vrai OCR Tesseract gratuitement** — **sans clé ni
16
+ installation** : déposez un corpus, lancez Tesseract, obtenez un rapport CER/WER/MER
17
+ réel. Il sert aussi des rapports `RunResult` pré-calculés, rendus en HTML autonome.
18
+
19
+ - `/` — liste des rapports disponibles
20
+ - `/library` — préparer un corpus (upload ZIP)
21
+ - `/benchmark` — lancer un run **Tesseract** (gratuit, local) sur un corpus
22
+ - `/reports/<nom>` — un rapport rendu en HTML
23
+ - `/health` — sonde de vivacité
24
+
25
+ **Par défaut, l'instance exécute ses moteurs avec la clé de son opérateur** —
26
+ aucun blocage. Sans clé posée, seul le **socle gratuit** tourne (Tesseract, aucun
27
+ secret, aucun appel facturé) ; dès qu'une clé est présente (`MISTRAL_API_KEY`,
28
+ `OPENAI_API_KEY`…), les moteurs **cloud** (OCR→LLM, VLM) s'exécutent normalement.
29
+
30
+ ## Utiliser un moteur cloud (Mistral, OpenAI…)
31
+
32
+ 1. **Secret** : `MISTRAL_API_KEY` = votre clé (Settings → Variables and secrets).
33
+ Le SDK `mistralai` est déjà dans `requirements.txt`. Redéployez.
34
+ 2. C'est tout : le menu déroulant « Modèle » du Banc d'essai se remplit directement
35
+ depuis l'API Mistral (`models.list`) et les prompts sont éditables par concurrent.
36
+
37
+ > 🔴 **Sécurité** : sur un Space **public**, la clé que vous posez est dépensable par
38
+ > n'importe quel visiteur. Rendez le Space **Private** (Settings → Visibility →
39
+ > Private) si vous y posez une clé facturée, **ou** activez le verrou ci-dessous.
40
+
41
+ ## Verrou « mode public » — opt-in, pour protéger une clé sur un Space public
42
+
43
+ Si vous tenez à exposer un Space **public** *tout en* gardant une clé posée sans
44
+ qu'un visiteur puisse la dépenser, activez le verrou **fail-closed** : variable
45
+ `CINOC_PUBLIC_MODE` = `true` (Settings → Variables and secrets). Alors seul le
46
+ **socle gratuit** (Tesseract) s'exécute ; les moteurs **cloud**, les **imports
47
+ distants** (IIIF/Gallica/…) et les **plugins tiers** sont **refusés** (`403`). Le
48
+ verrou est **désactivé par défaut** : ne le posez que si ce scénario est le vôtre.
49
+
50
+ > Ce fichier est l'en-tête de configuration du Space Hugging Face (`sdk: docker`).
51
+ > Au déploiement, il devient le `README.md` racine du dépôt du Space ; le
52
+ > `Dockerfile` et `requirements.txt` vivent dans `deploy/`.
cinoc/_version.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1.dev1+gea3d42207'
22
+ __version_tuple__ = version_tuple = (0, 1, 1, 'dev1', 'gea3d42207')
23
+
24
+ __commit_id__ = commit_id = 'gea3d42207'
cinoc/adapters/ANALYSE_COUCHE_5.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ANALYSE_COUCHE_5.md — `adapters/` (Picarones → Cinoc)
2
+
3
+ > **Type** : session d'ANALYSE (ne code rien). Guide de portage **durable**.
4
+ > **Couche** : 5 = `adapters` (cf. `CLAUDE.md` §3 : `domain(1) ← formats(2) ← evaluation(3) ← pipeline(4) ← adapters(5) ← app(6) ← reports(7) ← interfaces(8)`).
5
+ > **Source gelée** : `../Picarones/picarones/adapters/` — **41 fichiers, 9 682 LOC** (mesuré).
6
+ > **Cible** : `cinoc/adapters/`.
7
+ > **Couches amont mergées dont on dépend** : `domain` (mergée), `formats` (mergée), `evaluation` (plan acté `MIGRATION_COUCHE_3.md`), `pipeline` (à venir — porte le **contrat de module**).
8
+ > **Partie 1 = durable** (source figée). **Partie 2 = périssable** (« à confirmer à la tranche »).
9
+
10
+ ---
11
+
12
+ ## 0. Drapeaux à lever AVANT toute construction (lire en premier)
13
+
14
+ | # | Drapeau | Statut | Action |
15
+ |---|---|---|---|
16
+ | **F-0** | La consigne d'analyse disait « Couche4 », le titre/ livrable disent « couche 5 ». | Coquille. `CLAUDE.md` §3 + `PROMPT_ANALYSE_COUCHE.md` confirment **couche 5 = `adapters`**. | Aucun conflit. Analysé = `adapters`. |
17
+ | **F-1** | `CLAUDE.md` D1 décrit le legacy comme « double contrat **`BaseOCRAdapter`→`BaseModule`** ». **Le code gelé ne contient PAS cet héritage.** | `base.py:58` = `class BaseOCRAdapter(ABC)` ; `llm/base.py:202` = `class BaseLLMAdapter(ABC)` ; **aucun n'hérite de `BaseModule`**. Le vrai contrat runtime est **`StepExecutor`** (`pipeline/protocols.py:42`, `execute(...)`). `BaseModule` (`domain/module_protocol.py`, `process(...)`) est **vestigial** : sa docstring `:24-27` *prétend* (à tort) que les adapters en sont des cas particuliers ; ses seuls usages sont un ré-export dans `__init__` + `evaluation/metrics/module_policy.py` (lui-même classé « mort » par `MIGRATION_COUCHE_3.md` §11). | **Ne PAS s'arrêter** : la *décision* D1 (un seul `Protocol` en couche 4, `BaseModule` supprimé) reste **correcte et même renforcée** — Picarones a déjà ce `Protocol` unique (`StepExecutor`). Seule la *caractérisation factuelle* de la source est à corriger : il s'agit d'un contrat **vestigial parallèle** (`BaseModule.process`), pas d'un emballage. Documenté §1.2. |
18
+ | **F-2** | `CLAUDE.md` §8.8 dit « 8+8+8 adapters LLM/VLM ». | Réalité : **8 OCR + 4 LLM + 4 VLM**. | Direction (« → minimal/starter ») valide ; chiffres rectifiés §1.1. |
19
+ | **F-3** | `_fallback_log.py` n'a qu'un consommateur final : le **détecteur narratif** `IMPORTER_FALLBACK_TRIGGERED`. | Or le **moteur narratif est SUPPRIMÉ** (`CLAUDE.md` §6, D2). | ⇒ « pas de consommateur = supprimé ». Verdict **SUPPRIMER** (remplacé par `logger.warning`). §1.3-corpus. |
20
+
21
+ Aucun de ces points ne contredit une couche **mergée** ni une décision actée → analyse poursuivie.
22
+
23
+ ---
24
+
25
+ # PARTIE 1 — ANALYSE DE LA SOURCE PICARONES (durable)
26
+
27
+ ## 1.1 Inventaire exact (41 fichiers / 9 682 LOC)
28
+
29
+ | Sous-paquet | Fichiers | LOC | Rôle vérifié |
30
+ |---|---|---|---|
31
+ | **`/` (utils racine)** | 6 | **866** | I/O atomique, retry, http(x), downscale image, résolution de chemins. Mutualisés OCR+LLM. |
32
+ | **`ocr/`** | 12 | **3 206** | Contrat `BaseOCRAdapter` + factory + 8 moteurs + sidecar confidences. |
33
+ | **`corpus/`** | 8 | **3 045** | Importeurs de corpus patrimoniaux (IIIF, Gallica, eScriptorium, HTR-United, HuggingFace) + http urllib anti-SSRF + journal fallback. |
34
+ | **`llm/`** | 6 | **1 208** | Post-correction texte (`RAW_TEXT → CORRECTED_TEXT`). Base + 4 providers. |
35
+ | **`storage/`** | 3 | **964** | `ArtifactStore` (cache fichier) + `JobStore` (SQLite jobs web). |
36
+ | **`vlm/`** | 6 | **393** | Transcription `IMAGE → RAW_TEXT` zéro-OCR. Base + 4 manifestes de composition. |
37
+
38
+ Libs externes (toutes confinées à `adapters/`, conforme `CLAUDE.md` §3) : `pytesseract`+`PIL` (tesseract), `kraken`, `calamari-ocr`+TF, `pero-ocr`+torch, `google-cloud-vision`, `azure-ai-documentintelligence`, `mistralai`, `openai`, `anthropic`, `httpx` (ollama — **dép. cœur**, pas un extra), `datasets` (hf), `yaml`/`tqdm` (corpus). Le **noyau de contrat** (`ocr/base.py`, `factory.py`, `confidences.py`, `llm/base.py`, `vlm/base.py`, utils racine) n'importe **aucune** lib tierce sauf `httpx`/`PIL`.
39
+
40
+ ## 1.2 Le contrat réel (la couture décisive `domain↔pipeline↔adapters`)
41
+
42
+ **Trois contrats coexistent ; un seul est vivant côté runtime.**
43
+
44
+ | Contrat | Lieu | Forme | Statut réel |
45
+ |---|---|---|---|
46
+ | `BaseModule` | `domain/module_protocol.py:39` | `process(inputs)→outputs`, types `tuple`, **aucun** concern d'exécution | **Vestigial.** Aucun adapter ne l'hérite ni ne l'implémente. Consommateurs : ré-export `__init__` + `module_policy.py` (mort). |
47
+ | **`StepExecutor`** | `pipeline/protocols.py:42` | `name`,`input_types`/`output_types` (`frozenset`), **`execute(inputs, params, context: RunContext, control: RunControl) → dict[ArtifactType, Artifact]`** | **Contrat vivant.** Porte deadline (`context.deadline`) + annulation (`control`). Satisfait par **duck-typing** (jamais hérité). |
48
+ | `BaseOCRAdapter` / `BaseLLMAdapter` / `BaseVLMAdapter` | `adapters/{ocr,llm,vlm}/base.py` | ABC d'**implémentation** (défauts `input_types`/`output_types`, helpers, `name`/`execute` abstraits) | Ce sont des **mixins d'implémentation**, **pas** un second contrat. `BaseVLMAdapter(BaseLLMAdapter)` ; les deux autres `(ABC)`. |
49
+
50
+ **Résolution `adapter_name → executor`** : pas de registre. `build_adapter_resolver(engines)` (`app/services/_benchmark_adapter_resolver.py:215`) construit un `dict[str, StepExecutor]` à partir d'**instances** d'engines fabriquées en amont, et lève `PicaronesError` sur collision de `name` à configs distinctes. La factory `ocr_adapter_from_name` (`ocr/factory.py:64`) est une **cascade `if/elif` codée en dur** (anti-OCP). **Aucune découverte de plugins entry-points** (`registry_service.py:39` le dit explicitement). Seule extensibilité « tierce » : `adapter_class: dotted.path` dans le YAML `run_spec` (`app/schemas/run_spec.py:32-62`), classe importable dans le venv.
51
+
52
+ > ⚠️ **Conséquence pour Cinoc** : le `Protocol` de module que `CLAUDE.md` D1 veut construire en **couche 4** ≈ **`StepExecutor` déjà existant**. Les adapters l'implémentent **directement**. `BaseModule` est **supprimé sans perte**. *(`version` manque dans `StepExecutor`/`BaseOCRAdapter` → à **ajouter** pour la repro `RunManifest`, cf. §2.1.)*
53
+
54
+ ## 1.3 Verdicts fichier par fichier — **PROVISOIRE — à confirmer au build**
55
+
56
+ Légende verdict : **G**=garder · **M**=modifier · **C**=changer de couche · **S**=supprimer · **I**=incrémental (porter à la tranche qui le consomme, pas avant).
57
+
58
+ ### Utils racine (`adapters/*.py`)
59
+
60
+ | Fichier | LOC | Rôle vérifié | Consommateurs réels (hors tests) | Bugs / dette | Verdict |
61
+ |---|---|---|---|---|---|
62
+ | `__init__.py` | 28 | Docstring de paquet, `__all__=[]`. | 0 (imports par chemin complet). | — | **G** (mince) |
63
+ | `_atomic_io.py` | 151 | `atomic_write_text` (tmp+`fsync`+`os.replace`). | `atomic_write_text` : **11 adapters**. | `atomic_write_bytes` = **0 conso** (mort). Corps dupliqué text/bytes. | **M** : garder `atomic_write_text`, **S** `atomic_write_bytes`. **`storage/` doit l'utiliser** (cf. infra). |
64
+ | `_httpx_helpers.py` | 213 | Client httpx deadline-aware + cancel cross-thread (`make_httpx_client`, `translate_or_reraise`). | **1** (`llm/ollama_adapter`). | Détection « close » par **heuristique de message** (`:179-190`) fragile. Couple `adapters→pipeline.run_control`. | **G+M** (revoir l'heuristique ; unifier la trad. d'erreur). |
65
+ | `_image.py` | 87 | Downscale image base64 (`PIL` lazy, dégradable). | **3** (ollama/openai/mistral LLM). | Bloc d'appel `if max_edge>0: downscale` **copié 3×** chez les callers. | **G** (factoriser l'appel dans la base LLM). |
66
+ | `_retry.py` | 274 | Retry exponentiel + `Retry-After` + jitter, deadline-aware (`call_with_retry`, `is_retryable`, `compute_retry_wait`). | `call_with_retry` : **3 OCR cloud** ; primitives : `llm/base.py`. | **Bug** : jitter `×[1.0,1.5)` appliqué **après** le plafond → `DEFAULT_MAX_WAIT` (120 s) dépassé jusqu'à **180 s** (`:138-139`). `retry_after_seconds`/`DEFAULT_MAX_WAIT` exportés sans conso externe. **Boucle ré-implémentée** dans `llm/base.py:384-429` ET `corpus/_http.download_url`. | **M** : corriger le jitter ; **unifier les 3 implémentations** ; resserrer l'API publique. |
67
+ | `output_paths.py` | 113 | `resolve_output_path` (segment par pipeline anti-collision). | **9 adapters**. | `context: Any` (duck-typing `RunContext`) → dégrade en silence si attribut absent. | **G**. |
68
+
69
+ ### `ocr/`
70
+
71
+ | Fichier | LOC | Rôle / sortie | Consommateurs réels | Bugs / dette | Verdict |
72
+ |---|---|---|---|---|---|
73
+ | `__init__.py` | 48 | Façade ; ré-exporte les 8 adapters + base + factory. | traversé par `llm_pipeline_config`. | **Import eager** des 8 classes (sûr car les modules n'importent pas la lib lourde au top-level) mais court-circuite la robustesse lazy de la factory. | **M** (mince ; aligner sur le registre). |
74
+ | `base.py` | 189 | `BaseOCRAdapter(ABC)` + `OCRAdapterError`. **`effective_output_types`** = sous-ensemble **garanti** (vs maximal). | 8 adapters + resolver + factory + cli + robustness. | **Pas d'attribut `version`** (repro). `control` jamais honoré (cf. dette §1.4). | **M** : = mixin d'impl ; **ajouter `version`** ; garder `effective_output_types`. Contrat = `Protocol` couche 4. |
75
+ | `factory.py` | 194 | `ocr_adapter_from_name(name) → BaseOCRAdapter`. | cli + web + `__init__`. | **Cascade `if/elif`** (anti-OCP) ; alias incohérents (`calamari_ocr`→`calamari`, pas de court `kraken`) ; `_SUPPORTED`/`_ALIASES` divergent à la main ; message Azure cite `azure-ai-formrecognizer` ≠ SDK réel. | **C→app** : remplacer par **registre + entry-points** (`CLAUDE.md` §3 : registre = app/adapters, découverte = app). |
76
+ | `confidences.py` | 164 | Sidecar JSON de confidences. `ConfidenceToken`=TypedDict `{text, confidence∈[0,1]}`, **niveau mot**, normalisation Tesseract 0-100→0-1. | **1** (tesseract). Artefact `CONFIDENCES` lu par `evaluation/calibration`. | `model_version:null` toujours sérialisé ; entrée `list[dict]` non typée. | **G+M** : logique en adapters ; **promouvoir `ConfidenceToken`→`domain`** quand la calibration est consommée (backlog domain `CLAUDE.md`). |
77
+ | `precomputed.py` | 221 | **Starter.** Lit `<stem>.<label>.txt`. `IMAGE→RAW_TEXT`. 0 dép externe. | factory + builders. | Nommage `name` = `precomputed_<label>` (`_`) ≠ doc factory (`:`). `control` inutilisé ; double I/O policy `empty`. | **G** (starter) ; corriger nommage. |
78
+ | `tesseract.py` | 548 | **Starter.** `pytesseract` (binaire). `RAW_TEXT` (+`CONFIDENCES`/`ALTO_XML` **opt-in** via `effective_output_types`). | factory + cli + web. | >400 LOC. `control` inutilisé (timeout subprocess compense). | **G** (starter) ; **trim/split <400**. |
79
+ | `kraken.py` | 237 | HTR ML local (torch). `RAW_TEXT`. Extra `[kraken]`. | factory + web. | Deadline **ignorée** (inférence non interruptible) ; API Kraken 4.x fragile vs 5.x. | **G+I** (incrémental). |
80
+ | `calamari.py` | 253 | OCR ligne (TF). `RAW_TEXT`. Extra `[calamari]`. | factory + web. | Parsing résultat fragile (`getattr(...,"sentence","")`) ; deadline ignorée. | **G+I**. |
81
+ | `pero_ocr.py` | 226 | HTR ML local (torch, PAGE aplati). `RAW_TEXT`. | factory + web. | Docstring tronquée (`:1-3`) ; deadline ignorée ; ordre `page_size=(h,w)` à vérifier. | **G+I**. |
82
+ | `google_vision.py` | 330 | Cloud (SDK **ou** REST/urllib). `RAW_TEXT`. | factory + web. | SDK path ignore la deadline ; motif HTTP dupliqué (cf. azure/mistral). | **G+I**. |
83
+ | `azure_doc_intel.py` | 425 | Cloud async + polling (SDK ou REST). `RAW_TEXT`. | factory + web. | >400. **Meilleure gestion deadline** (REST) ; SDK path l'ignore. Backoff maison. | **G+I** ; trim<400. |
84
+ | `mistral_ocr.py` | 371 | `/v1/ocr` (urllib) **ou** chat/vision (SDK). `RAW_TEXT`. | factory + web. | SDK chat ignore deadline. **Importe `adapters.llm.base.normalize_llm_content`** (couplage cross-famille). | **G+I** ; sortir `normalize_*` en helper partagé. |
85
+
86
+ ### `llm/` + `vlm/`
87
+
88
+ | Fichier | LOC | Rôle / contrat | Consommateurs réels | Bugs / dette | Verdict |
89
+ |---|---|---|---|---|---|
90
+ | `llm/__init__.py` | 16 | Docstring cadrage. | 0. | — | **G**. |
91
+ | `llm/base.py` | 582 | **Cœur.** `BaseLLMAdapter(ABC)`. **Double API** : `complete(prompt, image_b64, *, deadline, control)→LLMResult` (appel + retry) **et** `execute(...)` (orchestration fichier→complete→fichier). `RAW_TEXT→CORRECTED_TEXT`. | pipeline (llm_config/builder) + VLM + `mistral_ocr` (helper). | >400. `LLMResult.tokens_used` **jamais peuplé** (`:194` vs `:396`) → coût non remonté. `except Exception`(`:401`) intentionnel+tracé. | **M** : **split <400** (helpers / `LLMResult` / base / orchestration) ; **peupler ou supprimer `tokens_used`**. |
92
+ | `llm/openai_adapter.py` | 123 | **Starter.** SDK `openai`, extra `[llm]`, `temperature=0`. | web factory. | `del control` (SDK non câblé, documenté). | **G** (starter). |
93
+ | `llm/ollama_adapter.py` | 160 | **Starter.** httpx brut (**dép. cœur**), **seul à câbler `control`** (cancel cross-thread). | web factory. | `except (TimeoutException, HTTPError): pass` (`:131-132`) — re-raise délégué, à mieux commenter. | **G** (starter). |
94
+ | `llm/anthropic_adapter.py` | 129 | Idem motif, extra `[llm]`. | web factory. | `del control`. | **G+I**. |
95
+ | `llm/mistral_adapter.py` | 198 | Idem + table `_TEXT_ONLY_MODELS` (dégradé multimodal). | web factory. | Logs verbeux ; instance client par appel. | **G+I**. |
96
+ | `vlm/__init__.py` | 42 | Façade ré-export. | 0 (hors tests). | — | **G+I**. |
97
+ | `vlm/base.py` | 245 | `BaseVLMAdapter(BaseLLMAdapter)` : `IMAGE→RAW_TEXT`, override `execute` + prompts ; **garde-fou MRO** (`__init_subclass__`). | 0 instanciateur (2 docs). | — | **G+I** (porter à la tranche `zero_shot`). Conserver le garde MRO. |
98
+ | `vlm/openai_vlm.py` | 22 | `(BaseVLMAdapter, OpenAIAdapter)` + `name`. | **0**. | — | **I/S** : ~90 % hérité, **0 conso** → ne **pas** porter spéculativement (porter à la tranche zero_shot). |
99
+ | `vlm/anthropic_vlm.py` | 32 | idem. | **0**. | — | **I/S** idem. |
100
+ | `vlm/mistral_vlm.py` | 26 | idem + `default_model=pixtral`. | **0**. | — | **I/S** idem. |
101
+ | `vlm/ollama_vlm.py` | 26 | idem + `default_model=llava`. | **0**. | — | **I/S** idem. |
102
+
103
+ ### `corpus/`
104
+
105
+ | Fichier | LOC | Source / sortie | Consommateurs réels | Bugs / dette | Verdict |
106
+ |---|---|---|---|---|---|
107
+ | `__init__.py` | 24 | Docstring « nommer/localiser (image, GT) » ; `__all__=[]`. | 0. | — | **G** (mince). |
108
+ | `_http.py` | 270 | urllib + **anti-SSRF** (`validate_http_url`, blocage IP réservées, revalidation redirects) + retry deadline-aware. | iiif, gallica, escriptorium. | `install_opener` = **effet de bord global au process** (`:159`). Boucle retry ≈ `_retry`. | **G+M** : brique sécurité réutilisable (invariant `CLAUDE.md`) ; retirer l'`install_opener` global ; unifier le retry. |
109
+ | `_fallback_log.py` | 98 | Journal mémoire des dégradations d'importeur. | `consume`←app `_benchmark_converter` → **détecteur narratif**. `record`←htr_united/hf. | Seul débouché = narratif. | **S** (narratif supprimé `CLAUDE.md` §6 → plus de consommateur ; remplacer par `logger.warning`). |
110
+ | `iiif.py` | 567 | Manifeste IIIF v2/v3 → **`Corpus`**. | CLI (`import iiif`) + web + gallica. | >400. **Fuite de fichiers temp** si `output_dir=None` (`NamedTemporaryFile(delete=False)`, `:465`). `tqdm` lazy. | **G+I+M** : trim<400 ; corriger la fuite temp. Référence du contrat « → Corpus ». |
111
+ | `gallica.py` | 563 | SRU+IIIF+OCR brut BnF → **`Corpus`** (délègue à `IIIFImporter`). | web seulement. | >400. **Bug latent** `selected_indices[i]+1` (additionne `IIIFCanvas`+int, `:415`). `_fetch_url` force `retries=1`. Beaucoup de surface morte (`search_gallica`, `get_metadata`…). | **M+I** : corriger le bug ; **S** des fonctions sans conso ; trim<400. |
112
+ | `escriptorium.py` | 571 | API REST eScriptorium → **`Corpus`** + écriture distante (export couche OCR). | web (`import_document` seul). | >400. **BUG dur** `Corpus(source=...)` (champ = `source_path`, `:450`) → `TypeError` au 1ᵉʳ appel réel, **masqué par les mocks**. `image_path:str` ≠ domain `Path`. ~40 % surface API morte. | **M+I** : corriger le bug ; **S** `export_benchmark_as_layer`/`list_*`/`connect_*` (0 conso) ; trim<400. |
113
+ | `htr_united.py` | 488 | Catalogue HTR-United (GitHub) → **dict-manifeste** (pas `Corpus`) + GT extraits (sans images). | web. | >400. **Pas de `_http`** (ni anti-SSRF ni retry). `except (URLError, Exception)`. ZIP **chargé entier en RAM**. `_DEMO_CATALOGUE`=**113 LOC de données fabriquées** dans le code. | **M+I** : sortie unifiée `Corpus` ; **données démo → `data/` (S du code)** ; passer par `_http` ; borner le download. |
114
+ | `huggingface.py` | 464 | HF Hub (lib `datasets`) → **dict-manifeste** + paires `.jpg`/`.gt.txt`. | web. | >400. **Monkey-patch au load** (`_patch_dataset_replace_source`, `:456`). `except (ImportError, Exception): return 0` **muet** (lib absente indistinguable de dataset cassé). GT orphelin si `image.save` échoue. `_REFERENCE_DATASETS`=**109 LOC** en dur. Docstring annonce des fonctions inexistantes. | **M+I** : sortie unifiée `Corpus` ; extra `[hf]` + message clair ; supprimer le monkey-patch ; données → `data/`. |
115
+
116
+ ### `storage/`
117
+
118
+ | Fichier | LOC | Backend / type | Consommateurs réels | Bugs / dette | Verdict |
119
+ |---|---|---|---|---|---|
120
+ | `__init__.py` | 58 | Façade ré-export. Docstring annonce 2 cibles **non livrées** (migration `web/jobs`, rapatriement history SQLite). | — | — | **G**. |
121
+ | `artifact_store.py` | 417 | **Filesystem** (`index.jsonl` + `artifacts/<key>.json` + `payloads/<key>.bin`) indexé par `ArtifactKey.hash_hex()`. Expose `StoredArtifact{key, artifact, payload}`. | **0 direct** : consommé via le **Protocol `ArtifactCachePort`** (`pipeline/cache_protocol.py`) injecté dans l'executor (inversion de dépendance). | >400. **`put` non atomique** (3 écritures sous simple `Lock`) ; **pas de `fsync`** (réimplémente un écriture inférieure au lieu d'utiliser `_atomic_io`) ; **path-traversal non gardé** (`f"{key}.json"`) ; index non borné ; pas de concurrence multi-process. | **G+M** : **utiliser `_atomic_io`** ; **valider la clé / `validated_path`** (invariant `CLAUDE.md`) ; implémente un **port défini en couche 4**. Tranche *cache*. |
122
+ | `job_store.py` | 489 | **SQLite** (table `jobs`, WAL, `schema_version`). `JobRecord`, `JobStore`. États `pending→running→{complete\|error\|cancelled\|interrupted}`. | **1** : `app/services/job_runner.py`. | >400. **Doublon** : un **2ᵉ `JobStore` legacy** vit en `interfaces/web/jobs.py:103` et c'est **lui** que l'app web tournante câble (`state.py:138`). `LIMIT` interpolé (neutralisé par `int()`). Pas de validation des transitions. | **G+M** : **UN seul** job store (celui-ci) ; **S** le doublon legacy (couche 8, hors périmètre mais à acter) — ⚠️ mais le canonical **n'a ni `job_events` ni reprise SSE `Last-Event-ID`** (assumé `job_store.py:48-54`), capacités que porte le legacy `web/jobs.py` **et dont dépend le stream SSE du web** (cf. `ANALYSE_COUCHE_8` §0/§1.3 D-γ) → **réabsorber l'événementiel AVANT** de supprimer le legacy ; valider les transitions. Tranche *web/async*. |
123
+
124
+ `StoredArtifact` : **non-candidat domain confirmé** (`domain/artifact_key.py:22-24` : « infrastructures, pas des types purs » ; le pipeline le touche via Protocol, pas en direct). Reste en `adapters/storage`.
125
+
126
+ ## 1.4 Dette transverse vérifiée (durable)
127
+
128
+ | # | Dette | Preuve | Portée |
129
+ |---|---|---|---|
130
+ | **D-A** | **`control: RunControl` jamais honoré** : tous les `execute()` OCR + LLM/VLM cloud le reçoivent mais **n'appellent jamais** `register_cancel_handle`. **Seul `ollama_adapter` câble l'annulation** (via `_httpx_helpers`). | `base.py:82-86` documente le contrat ; aucun adapter ne l'honore sauf ollama. | Gap d'annulation cross-thread généralisé. |
131
+ | **D-B** | **Deadline asymétrique** : Tesseract (timeout subprocess) + Azure-REST (polling clampé) la respectent ; **tous les paths SDK** (Azure SDK, Google SDK, Mistral chat) l'ignorent (limite SDK) ; kraken/calamari/pero l'ignorent totalement. | `azure_doc_intel.py:366-379` (bon) vs `:261` (SDK ignore) ; `mistral_ocr.py:310-316`. | Un run ML local/SDK peut dépasser le budget en silence. |
132
+ | **D-C** | **Retry implémenté 3×** : `_retry.call_with_retry`, `llm/base.py:384-429` (inline), `corpus/_http.download_url`. | grep. | Triple maintenance du même algorithme. |
133
+ | **D-D** | **Deux piles HTTP** dans la même couche : `httpx` (`_httpx_helpers`+`_retry`, pour OCR/LLM cloud) vs `urllib` (`corpus/_http`, anti-SSRF, pour importeurs). Aucune ne réutilise l'autre. | grep : `corpus/*` n'importe jamais `_retry`/`_httpx_helpers`. | Scission lib/menace **assumée** mais coûteuse. |
134
+ | **D-E** | **`__init__` à effet de bord** : `corpus/_http.py:159` fait `install_opener` **global** au process à l'import. | `:153-161`. | Viole l'esprit « `__init__` minces, aucun effet de bord » (`CLAUDE.md` §7). |
135
+ | **D-F** | **Fracture du contrat de sortie corpus** : iiif/gallica/escriptorium → `Corpus` ; **htr_united/huggingface → `dict`-manifeste** + effets disque. 3/5 écrivent des fichiers. | tableaux §1.3-corpus. | Pas de contrat unique d'ingestion. |
136
+ | **D-G** | **Code mort / surface non câblée** : `atomic_write_bytes` ; exports `_retry` sans conso ; ~40 % d'`escriptorium.py` ; commodités `gallica`/`hf` ; `JobRecord.is_terminal/is_live` ; `_MIGRATIONS` vide. | greps agents. | « pas de consommateur = supprimé ». |
137
+ | **D-H** | **Données fabriquées dans le code** : `_DEMO_CATALOGUE` (113 LOC) + `_REFERENCE_DATASETS` (109 LOC) = ~220 LOC de fausses données servies en repli. | `htr_united.py:44`, `huggingface.py`. | Risque d'afficher du faux contenu ; doit être de la **donnée** (`data/`) ou supprimé. |
138
+ | **D-I** | **Bugs latents masqués par les mocks** : `escriptorium Corpus(source=...)` (`:450`) ; `gallica selected_indices[i]+1` (`:415`). | lecture code + tests mockés. | Régression garantie au 1ᵉʳ appel réel → à corriger au portage. |
139
+ | **D-J** | **`execution_mode` : déjà retiré** (« mensonge structurel », commit `5e13c0d`, mai 2026 — attribut décoratif jamais lu par le runner thread-only). 0 occurrence prod ; garde-fou de test anti-résurrection. | git show. | **Leçon** : ne **jamais** réintroduire d'attribut « au cas où » non câblé. |
140
+
141
+ ---
142
+
143
+ # PARTIE 2 — RÉORGANISATION CIBLE Cinoc (périssable — « à confirmer à la tranche »)
144
+
145
+ > Conforme `CLAUDE.md` : **deux axes**, **tranches verticales** (jamais « finir la couche de haut en bas »), **budgets <400 LOC**, **rupture nette zéro shim**, **narrative supprimé**, **« pas de consommateur = supprimé »**. La couche 5 se **remplit par tranches** (chaque adapter naît avec le pipeline qui le consomme), pas en bloc.
146
+
147
+ ## 2.1 Enveloppe (axe 1 — dimensionnée plein-scope, mais **définie en couche 4**)
148
+
149
+ Le **contrat de module exécutable** n'est **pas** un livrable de la couche 5 : il vit en **couche 4** (`pipeline`, cf. `CLAUDE.md` D1). La couche 5 ne fait que **l'implémenter**. À acter quand on conçoit la couche 4 (rappelé ici car il façonne tous les adapters) :
150
+
151
+ - **`Protocol` unique** ≈ `StepExecutor` de Picarones : `name`, **`version`** (➕ vs Picarones, repro `RunManifest`), `input_types`/`output_types` (`frozenset[ArtifactType]`), `execute(inputs, params, context, control) → dict`.
152
+ - **`BaseModule` supprimé** (vestigial). Les ABC `Base*Adapter` deviennent de **simples mixins d'implémentation** (défauts + helpers + `_write_text_artifact()`), **pas** un contrat.
153
+ - **`effective_output_types`** (garanti ⊂ maximal) **conservé** — sinon `missing_output` casse les steps quand un extra opt-in (Tesseract `CONFIDENCES`/`ALTO_XML`) n'est pas produit.
154
+ - **Trancher la dette d'annulation `control`** (D-A/D-B) : soit honorer `control` partout où l'opération est annulable, soit assumer explicitement « best-effort deadline » (documenté, testé). Ne pas traîner un paramètre non câblé.
155
+
156
+ **Registre + découverte (couche `app`, cf. `CLAUDE.md` §3)** : remplacer la cascade `if/elif` + les factories CLI/web dispersées par **un registre type-driven `adapter_name → factory`** + **découverte entry-points `cinoc.modules`** + `register()` local. **Seul point d'extension tiers = les briques de pipeline** (OCR/HTR/VLM/post-correction/segmentation/ordre de lecture/NER). **Corpus, métriques, sections, stores = first-party, non pluggables.**
157
+
158
+ ## 2.2 Surface (axe 2 — incrémentale, par tranches)
159
+
160
+ **Starter pack** (le strict nécessaire au squelette ambulant) :
161
+
162
+ | Brique | Fichiers source | Extra | Tranche |
163
+ |---|---|---|---|
164
+ | `precomputed` (OCR 0 dép) | `ocr/precomputed.py` | — | **squelette texte** (corpus pré-calculé → CER) |
165
+ | `tesseract` (OCR binaire) | `ocr/tesseract.py` + `confidences.py` | binaire système | tôt (1ᵉʳ vrai moteur) |
166
+ | `openai` + `ollama` (LLM) | `llm/base.py` + `openai_adapter.py` + `ollama_adapter.py` | `[llm]` (openai) ; `httpx`=cœur (ollama) | tranche OCR+LLM `text_only` |
167
+ | utils racine | `_atomic_io`(text), `_retry`(corrigé+unifié), `output_paths`, `_image`, `_httpx_helpers` | — | portés **en bloc** avec le starter |
168
+
169
+ **Incrémental (porter à la tranche qui le consomme)** : anthropic/mistral (LLM) ; kraken/calamari/pero/google/azure/mistral_ocr (OCR — extras dédiés) ; **VLM** (tranche `zero_shot` — ~90 % gratuit une fois le LLM porté, **0 conso aujourd'hui → ne pas porter avant**) ; importeurs corpus (un par un) ; `artifact_store` (tranche *cache*) ; `job_store` (tranche *web/async*) ; **store longitudinal *tidy*** (tranche *longitudinal*, **rapatrié** de `evaluation/metrics/history.py` → `adapters/storage`, cf. `MIGRATION_COUCHE_3.md` §8).
170
+
171
+ ## 2.3 Arborescence cible proposée (à confirmer)
172
+
173
+ ```
174
+ cinoc/adapters/
175
+ ├── _atomic_io.py atomic_write_text (+fsync) [G, -atomic_write_bytes]
176
+ ├── _retry.py retry unifié, jitter corrigé [M, unifie les 3]
177
+ ├── _http.py transport + anti-SSRF + retry [fusion _httpx_helpers + corpus/_http ?]
178
+ ├── _image.py downscale (PIL lazy) [G]
179
+ ├── output_paths.py resolve_output_path [G]
180
+ ├── ocr/ base(mixin)+precomputed+tesseract [starter] ; reste incrémental
181
+ │ └── confidences.py (ConfidenceToken→domain à terme)
182
+ ├── llm/ base+openai+ollama [starter] ; anthropic/mistral incrémental
183
+ ├── vlm/ base+manifestes — tranche zero_shot uniquement
184
+ ├── corpus/ importeurs first-party, sortie UNIQUE = Corpus, 1 par tranche
185
+ │ [_fallback_log SUPPRIMÉ ; données démo → data/]
186
+ └── storage/ artifact_store + job_store (UNIQUE) + history tidy (rapatrié)
187
+ ```
188
+ *Note* : la **factory/registre** ne vit **pas** ici mais en `app/` (résolution `adapter_name`) ; la couche 5 ne fait qu'exposer des classes implémentant le `Protocol` couche 4.
189
+
190
+ ## 2.4 Application des 5 garde-fous
191
+
192
+ - **Rupture nette / zéro shim** : un seul `JobStore` (S le legacy `web/jobs.py`) ; un seul contrat de sortie corpus (`Corpus`, S les dict-manifestes) ; pas de `BaseModule` ; une seule pile HTTP si possible (D-D).
193
+ - **Budgets <400** : `llm/base.py`(582), `escriptorium`(571), `iiif`(567), `gallica`(563), `tesseract`(548), `job_store`(489), `htr_united`(488), `huggingface`(464), `azure`(425), `artifact_store`(417) **dépassent**. La plupart **rétrécissent** après retrait du code mort (D-G), des données démo (D-H) et dédup du boilerplate (`_write_text_artifact`, retry, HTTP). Sinon split ou entrée justifiée `test_file_budgets`.
194
+ - **Pas de consommateur = supprimé** : `atomic_write_bytes`, `_fallback_log`, surface morte d'escriptorium/gallica/hf, exports `_retry` inutiles, VLM concrets tant que `zero_shot` n'existe pas.
195
+ - **Tests d'archi jour 1** : whitelist `adapters/` = `domain` + `pipeline` + `formats` (+ `evaluation` pour le type `Corpus` chargé, **à expliciter** — Picarones le tolérait par wildcard) ; `no-side-effect-import` (tue le `install_opener` global D-E) ; `no-broad-except` (htr/hf `except Exception`) ; `file-budgets`.
196
+ - **Feature entière + élaguée** : chaque adapter porté **avec** sa correction de bug (D-I), son extra optionnel, son message clair d'absence de dép, sans annotation de sprint.
197
+
198
+ ## 2.5 Libs cibles & arbitrages (périssable — « à confirmer à la tranche »)
199
+
200
+ > Rappel : la minceur de la couche vient surtout de l'**élagage** (code mort, données démo, dédup), **pas** de l'ajout de libs. Une lib ne paie que si elle **retire plus de code que le wrapper qu'elle impose**. Contreparties transverses à toute dép : **supply-chain** (cf. `fastapi==0.136.3` / `MAL-2026-4750` exclue dans le `pyproject` Picarones ; `pip-audit` à tenir), **reproductibilité** (`dependencies_lock` du `RunManifest` → **épingler serré**), **code tiers in-process** (entry-points).
201
+
202
+ | Lib / appel | Verdict | Raison / condition |
203
+ |---|---|---|
204
+ | `httpx` (transport REST unique) | ✅ **conditionnel** | Pour le REST à **auth simple** (ollama, mistral `/v1/ocr`, HF, eScriptorium, htr_united, openai-compatible). Unifie les 2 piles HTTP (D-D), corrige la deadline (D-B). **Garder le SDK** pour Google/Azure (auth JWT service-account / AD lourde). Anti-SSRF toujours à coder (transport dédié, **sans `install_opener` global** — D-E). |
205
+ | Drop dual SDK/REST cloud | ✅ **conditionnel** | Seulement où le SDK n'offre ni timeout-par-appel ni annulation **et** l'auth est simple. Pas systématique. |
206
+ | `huggingface_hub` | ✅ **extra `[hf]`** | Remplace le REST urllib maison de `huggingface.py` (pagination tronquée, `except` muet, monkey-patch — D-G). Lib lourde/mouvante → confinée à l'extra, épinglée. |
207
+ | `importlib.metadata.entry_points` (stdlib) | ✅ **gaté** | Outil du point d'extension `cinoc.modules`. **Désactiver la découverte en mode public** (in-process = sécurité, `CLAUDE.md` §3) ; capter la **version** du plugin pour le `RunManifest`. |
208
+ | `pydantic` v2 | ✅ **configs** / ❌ **par-token** | OK pour les **configs d'adapter** (petites, 1×, aligne le repo). **Pas** pour `ConfidenceToken` (milliers/page → coût de validation, cf. `MIGRATION_COUCHE_2.md` G-D) : `TypedDict`/`dataclass(slots=True)`, valider une fois à la frontière. |
209
+ | `tenacity` (retry) | ❌ **garder maison** | Le retry doit être deadline/cancel-aware (`clamp_to_remaining`, `RunControl`), honorer `Retry-After`, et **testable déterministe** (horloge injectable). Tenacity impose un wrapper custom qui annule le gain. `_retry.py` maison (bug jitter corrigé) reste plus aligné. |
210
+ | anti-SSRF · parseur IIIF · subprocess Tesseract | ❌ **garder maison** | Pas de lib mainstream fiable (SSRF, IIIF) ; le subprocess `pytesseract` est **ce qui honore la deadline** pour Tesseract — un binding C-API (`tesserocr`) la perdrait. |
211
+ | ORM (SQLAlchemy) · async (`anyio`) | ❌ | `sqlite3` brut suffit (2 tables) ; runner **thread-only** acté (retrait `execution_mode`, D-J) → async = rupture injustifiée. |
212
+
213
+ **Cible LOC** (rappel, périssable) : enveloppe + starter pack utile ≈ **3 700 LOC** ; parité complète nettoyée ≈ **6 500–7 000 LOC** (vs **9 682** Picarones, ≈ **−30 %**). La couche se remplit par tranches — elle n'a jamais besoin de revoir les 9,7k. *(Détail des leviers de coupe : §1.4 + §2.4.)*
214
+
215
+ ---
216
+
217
+ # PARTIE 3 — RISQUES DE TRANSFERT & DETTES (avec détection)
218
+
219
+ | Risque | Détail | Comment détecter / désamorcer |
220
+ |---|---|---|
221
+ | **R-1 Contrat couche 4 manquant** | La couche 5 ne peut être portée **qu'après** le `Protocol` de module (couche 4) + `RunContext`/`RunControl`/`Deadline`. | Construire d'abord la tranche pipeline minimale ; un adapter starter (`precomputed`) sert de **premier client** du `Protocol`. |
222
+ | **R-2 `version` absent** | Picarones n'a pas d'attribut `version` sur les adapters → repro `RunManifest` incomplète. | Test : `RunManifest` doit capter `name+version` de chaque step ; refuser un module sans `version`. |
223
+ | **R-3 Annulation/deadline (D-A/D-B)** | `control` non câblé partout sauf ollama ; deadline ignorée par SDK/ML local. | Test d'annulation : lancer un step long, déclencher `control.cancel()`, vérifier l'arrêt < N s. Décider par adapter : annulable ou « best-effort » documenté. |
224
+ | **R-4 `ConfidenceToken`** | TypedDict local mot-niveau, échelle [0,1], normalisation Tesseract. À promouvoir en `domain` **quand** la métrique calibration le consomme. | Ne pas figer la forme avant le consommateur (backlog domain). Test de normalisation 0-100→0-1 + rejet <0/>100. |
225
+ | **R-5 Sortie corpus non uniforme (D-F)** | Choisir **`Corpus`** comme sortie unique ; pousser l'**orchestration disque** (download→write→manifest) en **service `app`**, garder en couche 5 le **transport + parsing schéma**. | Test : tout importeur retourne un `Corpus` valide (pas de `dict`). Séparer « localiser » (adapter) de « matérialiser » (app). |
226
+ | **R-6 Sécurité (anti-SSRF + chemins)** | `corpus/_http` porte l'anti-SSRF ; `artifact_store`/eScriptorium ne valident pas les chemins/URL. Invariant `CLAUDE.md` : `validated_path()` + URL contrôlée. | Test : URL loopback/IP réservée rejetée ; clé d'artefact `../` rejetée ; pas d'`install_opener` global (no-side-effect-import). |
227
+ | **R-7 Bugs latents (D-I)** | ✅ **entièrement clos**. `Corpus(source=)` eScriptorium (sortie `CorpusSpec`, `source` ∈ `metadata` → `TypeError` impossible). `selected_indices[i]+1` Gallica : mapping vue↔`f{n}` direct (position 1-based = numéro de vue), **aucune indirection** portée. Les deux prouvés non-mockés (`test_escriptorium_local_server.py`, `test_gallica_local_server.py`). | **Test d'intégration non-mocké** : loopback réel (`http.server`) par source — préféré au `live` quand la source exige auth/instance privée. |
228
+ | **R-8 Dérive de budget** | Fichiers riches >400. | `test_file_budgets` actif dès le 1ᵉʳ commit d'adapters ; budgets honnêtes, pas de gonflement silencieux. |
229
+ | **R-9 Couplage cross-famille** | `mistral_ocr` importe `llm.base.normalize_llm_content`. | Extraire la normalisation de contenu LLM en helper neutre partagé ; test d'archi sur les imports inter-sous-paquets. |
230
+ | **R-10 Deux JobStores** | Le legacy `interfaces/web/jobs.py` est celui réellement câblé. | Au portage : un seul store (`adapters/storage`), l'app web le consomme ; supprimer le legacy ; test qu'aucun `JobStore` ne vit hors `adapters/storage`. **⚠️ Le canonical doit d'abord réabsorber la table d'événements + la reprise `Last-Event-ID`** (absentes aujourd'hui), sinon le **stream SSE régresse** (cf. `ANALYSE_COUCHE_8` §0). |
231
+ | **R-11 Données démo en dur (D-H)** | ~220 LOC de faux catalogues. | Les sortir en `data/*.yaml` **ou** supprimer ; un repli démo ne doit jamais se faire passer pour de la vraie donnée (champ `is_demo` explicite). |
232
+ | **R-12 Numérique non rétro-compatible** | Comme la couche 2 (`MIGRATION_COUCHE_2.md` MIG-2) : Cinoc change normalisation/contrats → **toute « validation » par égalité de chiffres avec Picarones est invalide**. | Goldens **refaits**, pas hérités. |
233
+
234
+ ---
235
+
236
+ ## Résumé pour la session de CONSTRUCTION (3-5 points)
237
+
238
+ 1. **Le « double contrat » à tuer n'est pas un héritage** : Picarones a déjà **un** contrat runtime unique = `StepExecutor` (`pipeline/protocols.py`, `execute(inputs, params, context, control)`), que les adapters implémentent par duck-typing. `BaseModule` est **vestigial** (à supprimer sans perte). ⇒ Le `Protocol` couche 4 de Cinoc ≈ `StepExecutor` **+ un attribut `version`** ; les `Base*Adapter` deviennent de simples **mixins d'implémentation**.
239
+ 2. **Construire par tranches, starter pack d'abord** : `precomputed` + `tesseract`(+`confidences`) + `openai`+`ollama`, avec les utils racine (`_atomic_io`/`_retry` corrigé/`output_paths`/`_image`/`_httpx_helpers`) portés **en bloc**. Tout le reste (kraken/calamari/pero/cloud OCR, anthropic/mistral, **VLM**, importeurs, stores) est **incrémental — porté à la tranche qui le consomme**, jamais d'avance. **VLM = 0 consommateur aujourd'hui → ne pas porter avant la tranche `zero_shot`.**
240
+ 3. **Dettes à corriger AU portage (pas après)** : annulation `control` non câblée (D-A) + deadline asymétrique (D-B) à trancher ; **3 implémentations de retry à unifier** + bug jitter>plafond ; **bugs latents** `Corpus(source=)`/Gallica masqués par les mocks ; `artifact_store` doit utiliser `_atomic_io`+`fsync` et **valider les chemins** ; **un seul `JobStore`**.
241
+ 4. **Élagage discipliné** : **SUPPRIMER** `_fallback_log` (son seul débouché, le narratif, est supprimé), `atomic_write_bytes`, la surface morte (escriptorium/gallica/hf), `execution_mode` (déjà parti) ; **sortir** les ~220 LOC de données démo en `data/` ; **sortie corpus unique = `Corpus`** (fin des dict-manifestes). Registre/découverte = **couche `app`** (entry-points `cinoc.modules`), **seul point d'extension tiers = les briques de pipeline**.
242
+ 5. **Placement & dépendances** : les importeurs de corpus restent **first-party en couche 5** (transport + parsing → `Corpus`), l'**orchestration disque** remonte en service `app` ; le **store SQLite longitudinal/tidy** est **rapatrié** de `evaluation/` vers `adapters/storage` (cf. `MIGRATION_COUCHE_3.md`). Whitelist d'archi `adapters/` à **expliciter** (inclut `evaluation` pour le type `Corpus` chargé).
243
+
244
+ ## DoD vivante (couche 5) — **autorité de détail** ; le `MIGRATION_PLAN.md` indexe
245
+
246
+ > **T8 (D-066)** : tous les modules du socle renvoient `StepOutput` ; les adapters LLM/VLM remontent `tokens_in/out` (OpenAI/Mistral `usage.prompt/completion_tokens`, Anthropic `usage.input/output_tokens`, Ollama `prompt_eval_count`/`eval_count`) via `LLMCompletion` — un seul point de logique (`_base.run_llm_step`).
247
+
248
+ > **T12 (D-070)** : tesseract publie l'artefact `CONFIDENCES` (sidecar JSON de `ConfidenceToken`, `image_to_data` conf÷100, best-effort → sidecar vide + warning). Preuves : `tests/adapters/test_tesseract.py`.
249
+
250
+ > **T14 (D-072)** : `+ ocr/kraken.py` (HTR, extra `[kraken]`, `model` requis, RAW_TEXT+CONFIDENCES) `+ ocr/mistral_ocr.py` (cloud à la page, kind `mistral_ocr`). Preuves : `tests/adapters/test_kraken.py` · `test_mistral_ocr.py`.
251
+
252
+ > Tri-état : `[x]` fait **+ preuve** · `[ ]` à faire · `[~]` différé/réserve + raison.
253
+ > Maj dans le **même commit** que le code. **Statut : ✅ T3 complet** — `precomputed` + `tesseract` + **`openai`** + **`ollama`** (LLM post-correction) verts, annulation câblée (D-A clos) **+ `mistral`** (post-correction cloud, extra `[mistral]`, secret `MISTRAL_API_KEY`) ; stores/VLM **par tranches**. **+ T5 `layout/`** : `precomputed` (source LAYOUT + recognizer région), `assembler` (`AltoAssembler`), **`crop`** (`crop_region` PIL — découpe réelle des blocs du pipeline hybride seg→OCR ; `PIL` ajouté à la whitelist archi `adapters`). — *preuves : `tests/adapters/layout/test_crop.py`, `…/test_alto_assembler.py`, `tests/pipeline/test_hybrid_real_bnl.py` (live)*. **+ T7 `corpus/`** : 1ᵉʳ importeur **IIIF** — `_http` (transport `httpx` durci anti-SSRF : http(s) seul, rejet userinfo + IP non publiques, redirections re-validées, taille plafonnée, **aucun `install_opener` global** ; **anti-DNS-rebinding fermé** D-051 : résolution unique → IP validées **épinglées** via `network_backend` `httpcore` custom (`_PinnedBackend`), URL/`Host`/SNI/cert TLS inchangés, ré-épinglage par redirection — `httpcore` whitelisté en archi adapters + extras ; **`download` en flux disque** D-052 : `.part` au fil de l'eau + `os.replace` atomique + cap `IMAGE_MAX_BYTES`, aucun fichier partiel ; `download` accepte `headers` — média eScriptorium **même-hôte** seul porte le jeton) + `iiif` (parser manifeste v2/v3 → `IIIFImage`, **images seules**, pur) ; matérialisation disque → **`CorpusSpec`** en `app/corpus_import.py` (R-5 : localiser ≠ matérialiser). Sortie **unique `CorpusSpec`** (fin des dict-manifestes). — *preuves : `tests/adapters/corpus/{test_iiif_parse,test_http_ssrf,test_iiif_local_server}.py` (transport réel loopback), `…/test_iiif_live.py` (distant, opt-in), `tests/app/test_corpus_import.py`*. **+ eScriptorium** (porteur de **GT** → corpus **scorable**) : `escriptorium.py` (auth token, pagination `next`, `extract_gt_text` couche transcription, pur ; `_http.fetch_json` gagne `headers`) + `import_escriptorium_corpus` (image + `.gt.txt` → `GroundTruthRef` RAW_TEXT). **R-7/D-I fermé** : pas de `Corpus(source=)` (la sortie est `CorpusSpec`, `source` ∈ `metadata`) — prouvé **non-mocké** (test loopback : auth header + pagination 2 pages + GT lignes/`content`). — *preuves : `tests/adapters/corpus/{test_escriptorium_parse,test_escriptorium_local_server}.py`, `tests/app/test_corpus_import_escriptorium.py`*. **+ Gallica** : `gallica.py` (`normalize_ark` + `manifest_url` **IIIF `/iiif/`** + `fetch_ocr_text` via **ALTO** `RequestDigitalElement` + `alto_to_text` ; `_http.fetch_bytes` ajouté pour parser l'ALTO en octets) — **images réutilisent `IIIFImporter`**, OCR = **référence étiquetée** : type domaine `REFERENCE_TEXT` (D-053, ≠ GT manuelle) + `gt_source=gallica_ocr` — non scoré par défaut, vue référence dédiée (opt-in). **2 bugs prod corrigés (D-058, révélés par la capture réelle)** : URL manifeste `/iiif/` manquant (→ 403, masqué par les mocks) ; `texteBrut` ALTCHA-gated → bascule ALTO (rupture nette, `texteBrut`/`_looks_like_html` supprimés). **R-7 clos** : mapping vue *i* ↔ `f{i}` lu dans l'URL d'image (`/f{n}/`), **sans indirection**, prouvé non-mocké sur **vraies pages Gallica** (`tests/fixtures/gallica_alto/`, *Pourceaugnac*). — *preuves : `tests/adapters/corpus/{test_gallica_parse,test_gallica_alto,test_gallica_local_server}.py`, `tests/app/test_corpus_import_gallica.py`*. **+ HTR-United & HuggingFace** (**découverte**, pas matérialisation) : `htr_united.py` (`fetch_catalogue` YAML via `_http.fetch_text` + `parse_catalogue` pur + recherche + **repli démo `is_demo`** ; `yaml` ajouté à la whitelist adapters) ; `huggingface.py` (`search_reference` socle intégré + `HuggingFaceCatalogue.search` API publique **best-effort** via `_http.fetch_json`, `source` ∈ {reference, api}). **+ import HF matérialisant** (D-054, Lot D) : `stream_pages` (convention Cinoc `image`+`ground_truth`, lib `datasets` en extra `[huggingface]`, `streaming=True`, `Image(decode=False)` → octets sans PIL, `loader` injectable) → `app.import_hf_corpus` (GT `RAW_TEXT` réelle) + endpoint `/api/corpus/import/huggingface` (CSRF, gate public 403, non-conforme 422, extra absent 409). **Importeurs cœur complets.** — *preuves : `tests/adapters/corpus/{test_htr_united,test_htr_united_local_server,test_huggingface,test_huggingface_local_server}.py`*. **+ T7 `storage/history_store.py`** : `HistoryStore` SQLite **longitudinal** — `add(HistoryRecord…)` (idempotent, PK `(run_id,pipeline,view,metric)`), `history(pipeline,view,metric)` (chronologique), `regressions(view,metric, threshold, higher_is_better)` (2 derniers runs, direction-aware). Persistance **pure de lignes primitives** (n'importe pas `RunResult`) ; 1 connexion/opération (thread-safe). — *preuves : `tests/adapters/storage/test_history_store.py` (vrai SQLite : round-trip, idempotence, persistance inter-instances, régression/seuil/sens)*
254
+
255
+ **Enveloppe :**
256
+ - [x] `precomputed` implémente le `Module` Protocol (couche 4) **directement**, avec `name`/`version`. — *preuve : `tests/adapters/test_precomputed.py` (`isinstance(.., Module)` ; lecture `<stem>.<label>.txt` → `RAW_TEXT` + `content_hash` ; UTF-8 strict ; annulation)*
257
+ - [x] **1ᵉʳ vrai moteur : `tesseract`** (implémente `Module` directement ; `lang` anti-injection, timeout borné par la deadline, écrit dans le **workspace** ; pytesseract = **extra**, invocation **mockable** → CI sans binaire). — *preuve : `test_tesseract` (mock + validations + workspace + annulation) ; `test_live` opt-in*
258
+ - [x] **2ᵉ famille de module : LLM post-correction** (`openai`, mode `text_only`) — `RAW_TEXT → CORRECTED_TEXT` ; SDK extra, invocation **mockable** (CI sans clé) ; **pipeline multi-étapes OCR→LLM** prouvé (`CORRECTED_TEXT` **non vide**, bug historique). — *preuve : `test_openai` + `test_llm_pipeline`*
259
+ - [x] **2ᵉ LLM (généralisation prouvée) : `ollama`** — *même* contrat que `openai`, sortie `CORRECTED_TEXT`, **aucun cas particulier** ; transport `httpx` = **extra**, `_invoke_ollama` **mockable** (CI sans serveur). **Référence d'annulation câblée** : `_invoke_ollama` enregistre `client.close` via `RunControl.register_cancel_handle` → `trigger_cancel` ferme la connexion en vol ; « annulation vs panne » tranchée en sondant `is_cancelled` (`_fail_or_cancel`, **fiable** — remplace l'heuristique de message fragile, **D-A clos**). — *preuve : `test_ollama` (conformité Module + mock + workspace + annulation + `_fail_or_cancel` cancel/panne) ; mécanisme `register_cancel_handle` : `test_run_control` (fire-on-cancel · immédiat-si-déjà-annulé · once · ordre)*
260
+ - [~] VLM/`zero_shot` (0 conso) · `Base*Adapter` mixins · confidences/ALTO · `unregister`/déduplication des handles (accumulation bénigne, runs bornés) : **différés**.
261
+
262
+ **Garde-fous :**
263
+ - [x] `layer_dependencies` (whitelist `adapters` explicitée : domain+pipeline+formats) · `no_side_effect_imports` · `no_broad_except` · `file_budgets`. — *preuve : `test_adapters_imports_are_allowed` + suite archi verte.* `[~]` `install_opener` global (D-E) : tué à la tranche HTTP.
264
+
265
+ **Validation par tranche :** `MIGRATION_PLAN.md` §3 — starter pack via `Module` (T1-T3) · **1 seul `JobStore` avec SSE/`job_events` réabsorbés** (T4, cf. §0/D-γ) · bugs latents `Corpus(source=)`/`selected_indices+1` corrigés + test `live` (T7).
266
+
267
+ - [~] **Différé** : kraken/calamari/pero/cloud OCR, anthropic/mistral, **VLM (0 conso → pas avant `zero_shot`)**, importeurs (1/tranche, sortie unique `Corpus`), stores. **Supprimer** `_fallback_log` + données démo en dur (→`data/`).
268
+
269
+ ---
270
+
271
+ *Tous les verdicts de la Partie 1 sont marqués **PROVISOIRE — à confirmer au build** : le contact du code corrige souvent l'analyse.*
cinoc/adapters/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Couche 5 — ``adapters`` : modules concrets (starter pack).
2
+
3
+ Chaque adapter implémente directement le ``Module`` Protocol (couche 4). Les
4
+ dépendances lourdes (SDK LLM, moteurs OCR) sont des extras optionnels, importés
5
+ dans le module qui les utilise — jamais ici. ``__init__`` mince, sans effet de
6
+ bord.
7
+ """
8
+
9
+ from __future__ import annotations
cinoc/adapters/_resilience.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Résilience des appels réseau (couche 5, adapters) : cap de concurrence + retry.
2
+
3
+ Deux mécanismes, **par défaut, sans configuration**, pour qu'un run depuis l'UI
4
+ web (n'importe qui) ne sature pas une API instable :
5
+
6
+ - **Cap de concurrence par fournisseur** (``network_slot``) : un sémaphore borné
7
+ *par fournisseur* (Mistral, OpenAI… chacun le sien) → au plus N appels réseau
8
+ simultanés, quel que soit le nombre de threads du runner. C'est le fix
9
+ structurel du pool global partagé (Pero local + LLM réseau dans un même run).
10
+ - **Retry borné** (``call_resilient``) : sur 429/5xx, attend ``Retry-After`` si le
11
+ serveur l'indique, sinon un backoff exponentiel borné + gigue, puis recommence.
12
+
13
+ Stdlib pure (aucune dépendance). **Déterminisme (invariant §12)** : ces mécanismes
14
+ ne changent que le *timing*, jamais la valeur renvoyée → le ``RunResult`` reste
15
+ byte-identique. ``N`` = ``CINOC_NETWORK_CONCURRENCY`` (défaut 3, plancher 1).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ import random
23
+ import threading
24
+ import time
25
+ from collections.abc import Callable
26
+ from contextlib import AbstractContextManager
27
+ from typing import TypeVar
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ T = TypeVar("T")
32
+
33
+ #: Plafond par défaut d'appels réseau concurrents **par fournisseur** (anti-429).
34
+ _DEFAULT_LIMIT = 3
35
+ _LIMIT_ENV = "CINOC_NETWORK_CONCURRENCY"
36
+ #: Politique de retry : tentatives + backoff exponentiel borné (secondes).
37
+ _DEFAULT_ATTEMPTS = 5
38
+ _BASE_BACKOFF = 1.0
39
+ _MAX_BACKOFF = 30.0
40
+ #: Statuts HTTP retentés : 429 (rate limit) + 5xx transitoires (instabilité).
41
+ _RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
42
+
43
+ #: Registre process-global des sémaphores, créés paresseusement (aucun effet de
44
+ #: bord à l'import). Protégé par un verrou : ``network_slot`` est thread-safe.
45
+ _SEMAPHORES: dict[str, threading.BoundedSemaphore] = {}
46
+ _REGISTRY_LOCK = threading.Lock()
47
+
48
+
49
+ def _network_limit() -> int:
50
+ """Plafond effectif depuis l'environnement (défaut ``_DEFAULT_LIMIT``)."""
51
+ raw = os.environ.get(_LIMIT_ENV)
52
+ if raw is None:
53
+ return _DEFAULT_LIMIT
54
+ try:
55
+ value = int(raw)
56
+ except ValueError:
57
+ logger.warning(
58
+ "[resilience] %s=%r invalide (entier attendu) — défaut %d.",
59
+ _LIMIT_ENV,
60
+ raw,
61
+ _DEFAULT_LIMIT,
62
+ )
63
+ return _DEFAULT_LIMIT
64
+ return max(1, value)
65
+
66
+
67
+ def network_slot(provider: str) -> AbstractContextManager[bool]:
68
+ """Sémaphore borné **du fournisseur** : ``with network_slot("mistral"): …``.
69
+
70
+ Au plus ``_network_limit()`` détenteurs simultanés par fournisseur. Chaque
71
+ fournisseur a son quota propre (un run OpenAI + Mistral ne les fait pas se
72
+ partager le budget). Le plafond est figé à la première création du fournisseur
73
+ (lecture d'environnement unique, déterministe pour la durée du process)."""
74
+ with _REGISTRY_LOCK:
75
+ sem = _SEMAPHORES.get(provider)
76
+ if sem is None:
77
+ sem = threading.BoundedSemaphore(_network_limit())
78
+ _SEMAPHORES[provider] = sem
79
+ return sem
80
+
81
+
82
+ def _default_jitter() -> float:
83
+ return random.uniform(0.0, 1.0)
84
+
85
+
86
+ def _backoff(attempt: int, jitter: Callable[[], float]) -> float:
87
+ """Backoff exponentiel borné + gigue (désynchronise les threads concurrents)."""
88
+ return min(_MAX_BACKOFF, _BASE_BACKOFF * (2.0**attempt)) + jitter()
89
+
90
+
91
+ def http_is_retryable(exc: BaseException) -> bool:
92
+ """Vrai si l'exception SDK porte un statut HTTP transitoire (429/5xx).
93
+
94
+ Duck-typing assumé : les SDK httpx (mistralai/openai/anthropic) exposent
95
+ ``status_code``. Une erreur sans statut (réseau coupé, bug local) n'est **pas**
96
+ retentée — on ne masque pas un vrai défaut."""
97
+ return getattr(exc, "status_code", None) in _RETRYABLE_STATUS
98
+
99
+
100
+ def http_retry_after(exc: BaseException) -> float | None:
101
+ """Délai ``Retry-After`` (secondes, borné) si le serveur l'indique, sinon ``None``.
102
+
103
+ Sonde l'en-tête sur ``exc.response.headers`` puis ``exc.headers`` ; tolère
104
+ toute absence/forme invalide (→ ``None`` → l'appelant calcule un backoff)."""
105
+ for holder in (getattr(exc, "response", None), exc):
106
+ headers = getattr(holder, "headers", None)
107
+ if headers is None or not hasattr(headers, "get"):
108
+ continue
109
+ raw = headers.get("retry-after")
110
+ if raw is None:
111
+ continue
112
+ try:
113
+ seconds = float(raw)
114
+ except (TypeError, ValueError):
115
+ continue
116
+ if seconds >= 0:
117
+ return min(_MAX_BACKOFF, seconds)
118
+ return None
119
+
120
+
121
+ def call_resilient(
122
+ provider: str,
123
+ fn: Callable[[], T],
124
+ *,
125
+ is_retryable: Callable[[BaseException], bool] = http_is_retryable,
126
+ retry_after: Callable[[BaseException], float | None] = http_retry_after,
127
+ attempts: int = _DEFAULT_ATTEMPTS,
128
+ sleep: Callable[[float], None] = time.sleep,
129
+ jitter: Callable[[], float] = _default_jitter,
130
+ ) -> T:
131
+ """Exécute ``fn`` sous le quota du fournisseur, avec retry borné sur 429/5xx.
132
+
133
+ Acquiert ``network_slot(provider)`` **par tentative** (relâché pendant
134
+ l'attente → on ne gèle pas un quota en dormant). Sur erreur retentable et tant
135
+ qu'il reste des tentatives : attend ``Retry-After`` si fourni, sinon un backoff
136
+ exponentiel borné + gigue, puis recommence. Sinon l'exception remonte
137
+ (l'appelant la traduit en ``AdapterStepError`` → unité isolée par le runner).
138
+ ``sleep``/``jitter`` injectables (tests déterministes). **Ne change que le
139
+ *timing*, jamais la valeur renvoyée.**"""
140
+ for attempt in range(attempts):
141
+ delay = 0.0
142
+ with network_slot(provider):
143
+ try:
144
+ return fn()
145
+ except Exception as exc:
146
+ if not is_retryable(exc) or attempt == attempts - 1:
147
+ raise
148
+ after = retry_after(exc)
149
+ delay = after if after is not None else _backoff(attempt, jitter)
150
+ logger.warning(
151
+ "[resilience] %s : tentative %d/%d échouée (%s) — "
152
+ "nouvelle tentative dans %.1f s.",
153
+ provider,
154
+ attempt + 1,
155
+ attempts,
156
+ type(exc).__name__,
157
+ delay,
158
+ )
159
+ sleep(delay) # hors du créneau : on ne retient pas le quota en dormant
160
+ raise AssertionError( # pragma: no cover — la boucle retourne ou relance avant
161
+ "call_resilient : boucle épuisée sans issue"
162
+ )
163
+
164
+
165
+ __all__ = [
166
+ "call_resilient",
167
+ "http_is_retryable",
168
+ "http_retry_after",
169
+ "network_slot",
170
+ ]
cinoc/adapters/_workspace.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chemins de sortie dans le workspace (couche 5, partagé par les écrivains).
2
+
3
+ Les adapters qui **produisent** un artefact (OCR, post-correction LLM) y déposent
4
+ un fichier. Le ``stem`` dérivé du ``document_id`` est **injectif** et sans
5
+ séparateur de chemin : encodage type URL (``quote``), **réversible** donc
6
+ injectif, qui neutralise ``/`` (→ ``%2F``) et l'octet nul (→ ``%00``). Deux ids
7
+ distincts ne peuvent jamais produire le même nom de fichier (et donc s'écraser) —
8
+ y compris les cas limites ``a/_b`` vs ``a_/b`` qu'un simple échappement maison
9
+ collisionnerait. Les ids courants (alphanumériques) restent lisibles.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from urllib.parse import quote
16
+
17
+
18
+ def safe_document_stem(document_id: str) -> str:
19
+ """``document_id`` → composant de nom de fichier **injectif** et sans ``/``."""
20
+ return quote(document_id, safe="")
21
+
22
+
23
+ def workspace_artifact_path(
24
+ workspace_uri: str, document_id: str, label: str, suffix: str
25
+ ) -> Path:
26
+ """Chemin ``<workspace>/<stem>.<label>.<suffix>`` (``stem`` injectif)."""
27
+ stem = safe_document_stem(document_id)
28
+ return Path(workspace_uri) / f"{stem}.{label}.{suffix}"
29
+
30
+
31
+ __all__ = ["safe_document_stem", "workspace_artifact_path"]
cinoc/adapters/corpus/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Importeurs de corpus first-party (couche 5).
2
+
3
+ Chaque importeur **localise** des ressources (transport + parsing de schéma) et
4
+ retourne un type neutre ; la **matérialisation disque → ``CorpusSpec``** vit en
5
+ couche ``app``. Module mince, aucun effet de bord à l'import.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from cinoc.adapters.corpus.escriptorium import (
11
+ EScriptoriumImporter,
12
+ EScriptoriumPage,
13
+ )
14
+ from cinoc.adapters.corpus.gallica import GallicaImporter, normalize_ark
15
+ from cinoc.adapters.corpus.htr_united import (
16
+ HTRUnitedCatalogue,
17
+ HTRUnitedEntry,
18
+ fetch_catalogue,
19
+ )
20
+ from cinoc.adapters.corpus.huggingface import (
21
+ HuggingFaceCatalogue,
22
+ HuggingFaceDataset,
23
+ )
24
+ from cinoc.adapters.corpus.iiif import IIIFImage, IIIFImporter, parse_manifest
25
+
26
+ __all__ = [
27
+ "EScriptoriumImporter",
28
+ "EScriptoriumPage",
29
+ "GallicaImporter",
30
+ "HTRUnitedCatalogue",
31
+ "HTRUnitedEntry",
32
+ "HuggingFaceCatalogue",
33
+ "HuggingFaceDataset",
34
+ "IIIFImage",
35
+ "IIIFImporter",
36
+ "fetch_catalogue",
37
+ "normalize_ark",
38
+ "parse_manifest",
39
+ ]
cinoc/adapters/corpus/_http.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transport HTTP **durci anti-SSRF** pour les importeurs de corpus (couche 5).
2
+
3
+ Un importeur va chercher des ressources à une URL **fournie par l'utilisateur** :
4
+ le risque est qu'on serve de proxy vers le réseau interne (SSRF) ou qu'on télécharge
5
+ sans borne (DoS mémoire/disque). Défenses, toutes testées :
6
+
7
+ - **schéma** ``http``/``https`` seuls (pas de ``file://``, ``gopher://``…) ;
8
+ - **pas de userinfo** dans l'URL ;
9
+ - **résolution DNS validée** : *toutes* les IP résolues doivent être **publiques**
10
+ (rejet loopback / privé / link-local / réservé / multicast / non-spécifié) ;
11
+ une IP **littérale** n'est pas résolue du tout, et une adresse qui **encapsule**
12
+ une IPv4 (IPv4-mapped, NAT64 bien connu) est jugée sur cette IPv4 ;
13
+ - **redirections re-validées** à chaque saut (une 30x ne peut pas pointer vers
14
+ l'interne) ; nombre de sauts borné ;
15
+ - **taille plafonnée** au fil de l'eau (on ne fait pas confiance à
16
+ ``Content-Length``).
17
+
18
+ **DNS-rebinding fermé — épinglage d'IP.** ``assert_public_url`` résout l'hôte
19
+ **une seule fois**, valide **toutes** les IP, et **retourne** ces IP. La
20
+ connexion vise alors directement l'IP **épinglée** (transport ``httpx`` à
21
+ ``network_backend`` custom, cf. :class:`_PinnedBackend`) : il n'y a **pas de
22
+ seconde résolution** que pourrait détourner un DNS hostile. Le nom d'hôte de
23
+ l'URL reste inchangé, donc le ``Host`` envoyé, le **SNI** et la vérification du
24
+ certificat TLS portent toujours sur le vrai hôte (aucun override fragile) —
25
+ seule la cible TCP est figée. L'épinglage est ré-appliqué **à chaque saut** de
26
+ redirection. Le **gate « mode public » (403)** côté interface reste la première
27
+ barrière (les imports distants y sont refusés) ; l'épinglage est la défense en
28
+ profondeur, désormais étanche.
29
+
30
+ Auth & redirections : les en-têtes (jeton) ne suivent **pas** un changement
31
+ d'hôte (cf. ``_stream_validated``). Une seule pile HTTP (``httpx`` + son moteur
32
+ ``httpcore``) ; **aucun état global** (pas d'``install_opener``).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import contextlib
38
+ import ipaddress
39
+ import json
40
+ import os
41
+ import socket
42
+ import typing
43
+ from collections.abc import Iterator
44
+ from pathlib import Path
45
+ from urllib.parse import urlsplit
46
+
47
+ import httpcore
48
+ import httpx
49
+
50
+ from cinoc.domain.errors import CinocError
51
+
52
+ #: Plafonds (généreux mais bornés — un Space gratuit n'est pas un entrepôt).
53
+ MANIFEST_MAX_BYTES = 16 * 1024 * 1024
54
+ IMAGE_MAX_BYTES = 50 * 1024 * 1024
55
+ DEFAULT_TIMEOUT = 30.0
56
+ _MAX_REDIRECTS = 5
57
+ _ALLOWED_SCHEMES = frozenset({"http", "https"})
58
+
59
+
60
+ class CorpusHttpError(CinocError):
61
+ """Échec de transport d'un importeur de corpus."""
62
+
63
+
64
+ class SsrfError(CorpusHttpError):
65
+ """L'URL cible une ressource interdite (schéma, userinfo ou IP non publique)."""
66
+
67
+
68
+ class HttpFetchError(CorpusHttpError):
69
+ """La requête a échoué (statut, redirection cassée, dépassement de taille)."""
70
+
71
+
72
+ #: Préfixe NAT64 « bien connu » (RFC 6052 §2.1) : les **32 derniers bits**
73
+ #: portent l'IPv4 traduite. Sur un réseau IPv6-seul (DNS64 — macOS et beaucoup
74
+ #: de réseaux mobiles), *toute* résolution d'un hôte IPv4 passe par là. Juger
75
+ #: l'enveloppe IPv6 au lieu de l'IPv4 embarquée revient à refuser Gallica, IIIF
76
+ #: et HuggingFace : ``64:ff9b::`` commence par un octet nul, donc tombe dans
77
+ #: ``::/8``, que ``ipaddress`` classe **réservé**.
78
+ _NAT64_WELL_KNOWN = ipaddress.IPv6Network("64:ff9b::/96")
79
+
80
+
81
+ def _embedded_ipv4(
82
+ ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
83
+ ) -> ipaddress.IPv4Address | None:
84
+ """L'IPv4 réellement visée par ``ip``, ou ``None`` si elle n'en encapsule pas.
85
+
86
+ Deux encapsulations existent : ``::ffff:a.b.c.d`` (IPv4-mapped) et le NAT64
87
+ bien connu. Un préfixe NAT64 **propre au site** (RFC 6052 §2.2) est
88
+ indétectable et reste jugé comme l'IPv6 globale qu'il est.
89
+ """
90
+ mapped = getattr(ip, "ipv4_mapped", None)
91
+ if mapped is not None:
92
+ return typing.cast(ipaddress.IPv4Address, mapped)
93
+ if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WELL_KNOWN:
94
+ return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
95
+ return None
96
+
97
+
98
+ def _is_public(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
99
+ """``ip`` vise-t-elle une ressource publique ?
100
+
101
+ Une adresse qui **encapsule** une IPv4 est jugée sur cette IPv4 : le filtre
102
+ ne se laisse donc pas contourner par l'enveloppe (``64:ff9b::7f00:1`` est
103
+ du loopback, et refusé) ni ne refuse à tort une cible publique traduite.
104
+ """
105
+ embedded = _embedded_ipv4(ip)
106
+ if embedded is not None:
107
+ ip = embedded
108
+ return not (
109
+ ip.is_private
110
+ or ip.is_loopback
111
+ or ip.is_link_local
112
+ or ip.is_reserved
113
+ or ip.is_multicast
114
+ or ip.is_unspecified
115
+ )
116
+
117
+
118
+ def assert_public_url(url: str) -> tuple[str, ...]:
119
+ """Valide ``url`` et **retourne les IP publiques** vers lesquelles épingler.
120
+
121
+ Résout l'hôte **une seule fois** et exige que **toutes** les IP retournées
122
+ soient publiques : un nom qui résout (même partiellement) vers l'interne est
123
+ rejeté (``SsrfError``). Les IP validées sont retournées pour que l'appelant
124
+ **épingle** la connexion dessus (anti-DNS-rebinding) — pas de seconde
125
+ résolution. Pour une IP littérale, retourne cette IP (aucun DNS réel).
126
+ """
127
+ parts = urlsplit(url)
128
+ if parts.scheme not in _ALLOWED_SCHEMES:
129
+ raise SsrfError(f"schéma non autorisé : {parts.scheme!r} (http/https seuls).")
130
+ if parts.username or parts.password:
131
+ raise SsrfError("userinfo interdit dans l'URL.")
132
+ host = parts.hostname
133
+ if not host:
134
+ raise SsrfError("URL sans hôte.")
135
+ try:
136
+ literal = ipaddress.ip_address(host)
137
+ except ValueError:
138
+ literal = None # type: ignore[assignment]
139
+ if literal is not None:
140
+ # Promesse tenue : une IP littérale ne passe **pas** par le résolveur.
141
+ # Y passer laissait un DNS64 substituer une adresse NAT64 à l'IP écrite
142
+ # noir sur blanc dans l'URL — et la rejeter.
143
+ if not _is_public(literal):
144
+ raise SsrfError(f"IP non publique : {host}.")
145
+ return (host,)
146
+ port = parts.port or (443 if parts.scheme == "https" else 80)
147
+ try:
148
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
149
+ except OSError as exc:
150
+ raise SsrfError(f"résolution DNS impossible pour {host!r} : {exc}.") from exc
151
+ pins: list[str] = []
152
+ for info in infos:
153
+ addr = str(info[4][0]) # sockaddr[0] = adresse textuelle (IPv4/IPv6)
154
+ if not _is_public(ipaddress.ip_address(addr)):
155
+ raise SsrfError(
156
+ f"hôte {host!r} résout vers une IP non publique ({addr})."
157
+ )
158
+ if addr not in pins:
159
+ pins.append(addr)
160
+ return tuple(pins)
161
+
162
+
163
+ class _PinnedBackend(httpcore.SyncBackend):
164
+ """Épingle la cible TCP sur des IP **pré-validées** (anti-DNS-rebinding).
165
+
166
+ ``httpcore`` appelle ``connect_tcp(host, port)`` avec le **nom d'hôte** de
167
+ l'origine ; on substitue les IP déjà résolues+validées, sans repasser par le
168
+ DNS. Le nom d'hôte de l'URL restant inchangé, le SNI et la vérification du
169
+ certificat TLS (pilotés par ``httpcore`` depuis l'origin) portent toujours
170
+ sur le **vrai hôte** — donc aucun override fragile. On essaie les IP dans
171
+ l'ordre (comme ``socket.create_connection`` le ferait), préservant le
172
+ repli multi-adresses.
173
+ """
174
+
175
+ def __init__(self, ips: tuple[str, ...]) -> None:
176
+ super().__init__()
177
+ self._ips = ips
178
+
179
+ def connect_tcp(
180
+ self,
181
+ host: str,
182
+ port: int,
183
+ timeout: float | None = None,
184
+ local_address: str | None = None,
185
+ socket_options: typing.Any = None,
186
+ ) -> httpcore.NetworkStream:
187
+ last_exc: httpcore.ConnectError | None = None
188
+ for ip in self._ips:
189
+ try:
190
+ return super().connect_tcp(
191
+ ip,
192
+ port,
193
+ timeout=timeout,
194
+ local_address=local_address,
195
+ socket_options=socket_options,
196
+ )
197
+ except httpcore.ConnectError as exc:
198
+ last_exc = exc
199
+ assert last_exc is not None # _ips est non vide (cf. _make_client)
200
+ raise last_exc
201
+
202
+
203
+ def _pinned_transport(ips: tuple[str, ...]) -> httpx.HTTPTransport:
204
+ """Transport ``httpx`` dont le backend réseau épingle ``ips``.
205
+
206
+ ``httpx`` n'expose pas ``network_backend`` ; on le pose sur le pool interne
207
+ (seul point d'injection). Le ``isinstance`` fait échouer **bruyamment** si
208
+ une future version de ``httpx`` change la forme du pool, plutôt que de
209
+ contourner silencieusement l'épinglage.
210
+ """
211
+ transport = httpx.HTTPTransport()
212
+ pool = transport._pool
213
+ if not isinstance(pool, httpcore.ConnectionPool): # pragma: no cover
214
+ transport.close()
215
+ raise CorpusHttpError("épinglage IP indisponible : transport httpx inattendu.")
216
+ pool._network_backend = _PinnedBackend(ips)
217
+ return transport
218
+
219
+
220
+ def _make_client(pins: tuple[str, ...] | None, timeout: float) -> httpx.Client:
221
+ """Client mono-saut. ``pins`` vide/None → résolution httpx normale.
222
+
223
+ Le cas ``None`` n'arrive qu'en test (``assert_public_url`` neutralisé en
224
+ no-op pour viser le loopback) ; en production il est toujours non vide.
225
+ """
226
+ if not pins:
227
+ return httpx.Client(timeout=timeout, follow_redirects=False)
228
+ return httpx.Client(
229
+ transport=_pinned_transport(pins), timeout=timeout, follow_redirects=False
230
+ )
231
+
232
+
233
+ @contextlib.contextmanager
234
+ def _stream_validated(
235
+ url: str, *, timeout: float, headers: dict[str, str] | None = None
236
+ ) -> Iterator[httpx.Response]:
237
+ """GET en streaming, **épinglé à l'IP validée** et re-validé à chaque saut.
238
+
239
+ Chaque saut (origine + redirections, bornées) : résout+valide une fois via
240
+ ``assert_public_url``, épingle la connexion sur l'IP retournée, puis envoie.
241
+ Les en-têtes fournis (ex. ``Authorization: Token …``) ne sont **jamais**
242
+ propagés vers un **hôte différent** de l'origine (fuite de credentials sur
243
+ redirection). Un client ``httpx`` distinct est créé par saut (épinglage
244
+ propre à l'IP du saut) ; tous sont fermés à la sortie du contexte.
245
+ """
246
+ origin_host = urlsplit(url).hostname
247
+ current = url
248
+ with contextlib.ExitStack() as stack:
249
+ for _ in range(_MAX_REDIRECTS + 1):
250
+ pins = assert_public_url(current)
251
+ # Auth conservée seulement tant qu'on reste sur l'hôte d'origine.
252
+ hop_headers = headers if urlsplit(current).hostname == origin_host else None
253
+ client = stack.enter_context(_make_client(pins, timeout))
254
+ request = client.build_request("GET", current, headers=hop_headers)
255
+ response = client.send(request, stream=True)
256
+ if response.is_redirect:
257
+ location = response.headers.get("location", "")
258
+ response.close()
259
+ if not location:
260
+ raise HttpFetchError(
261
+ f"redirection sans 'Location' depuis {current!r}."
262
+ )
263
+ current = str(request.url.join(location))
264
+ continue
265
+ try:
266
+ yield response
267
+ finally:
268
+ response.close()
269
+ return
270
+ raise HttpFetchError(f"trop de redirections (> {_MAX_REDIRECTS}) pour {url!r}.")
271
+
272
+
273
+ def _read_capped(response: httpx.Response, max_bytes: int) -> bytes:
274
+ chunks: list[bytes] = []
275
+ total = 0
276
+ for chunk in response.iter_bytes():
277
+ total += len(chunk)
278
+ if total > max_bytes:
279
+ raise HttpFetchError(f"réponse dépasse le plafond de {max_bytes} octets.")
280
+ chunks.append(chunk)
281
+ return b"".join(chunks)
282
+
283
+
284
+ def fetch_json(
285
+ url: str,
286
+ *,
287
+ timeout: float = DEFAULT_TIMEOUT,
288
+ headers: dict[str, str] | None = None,
289
+ ) -> object:
290
+ """Récupère et parse un JSON ; anti-SSRF + plafond ``MANIFEST_MAX_BYTES``.
291
+
292
+ ``headers`` permet l'authentification d'API (ex. ``Authorization: Token …``) ;
293
+ il n'est jamais journalisé (les messages d'erreur ne portent que l'URL).
294
+ """
295
+ try:
296
+ with _stream_validated(url, timeout=timeout, headers=headers) as response:
297
+ response.raise_for_status()
298
+ raw = _read_capped(response, MANIFEST_MAX_BYTES)
299
+ except httpx.HTTPStatusError as exc:
300
+ raise HttpFetchError(
301
+ f"statut HTTP {exc.response.status_code} sur {url!r}."
302
+ ) from exc
303
+ except httpx.HTTPError as exc:
304
+ raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
305
+ try:
306
+ return json.loads(raw)
307
+ except json.JSONDecodeError as exc:
308
+ raise HttpFetchError(f"réponse non-JSON depuis {url!r} : {exc}.") from exc
309
+
310
+
311
+ def download(
312
+ url: str,
313
+ dest: Path,
314
+ *,
315
+ timeout: float = DEFAULT_TIMEOUT,
316
+ headers: dict[str, str] | None = None,
317
+ ) -> None:
318
+ """Télécharge ``url`` vers ``dest`` en **flux disque** ; anti-SSRF + plafond.
319
+
320
+ Écrit au fil de l'eau dans un fichier ``.part`` voisin (jamais tout le corps
321
+ en RAM), plafonne à ``IMAGE_MAX_BYTES``, puis **renomme atomiquement**
322
+ (``os.replace``) vers ``dest``. À la moindre erreur (statut, dépassement de
323
+ plafond, I/O, annulation), le ``.part`` est supprimé : **aucun fichier
324
+ partiel** n'est laissé derrière, et ``dest`` n'apparaît qu'une fois complet.
325
+
326
+ ``headers`` (ex. ``Authorization``) n'est transmis qu'à l'hôte d'origine —
327
+ ``_stream_validated`` le retire sur une redirection cross-hôte (cf. D-050).
328
+ """
329
+ part = dest.with_name(dest.name + ".part")
330
+ try:
331
+ with _stream_validated(url, timeout=timeout, headers=headers) as response:
332
+ try:
333
+ response.raise_for_status()
334
+ except httpx.HTTPStatusError as exc:
335
+ raise HttpFetchError(
336
+ f"statut HTTP {exc.response.status_code} sur {url!r}."
337
+ ) from exc
338
+ total = 0
339
+ with part.open("wb") as handle:
340
+ for chunk in response.iter_bytes():
341
+ total += len(chunk)
342
+ if total > IMAGE_MAX_BYTES:
343
+ raise HttpFetchError(
344
+ f"réponse dépasse le plafond de {IMAGE_MAX_BYTES} octets."
345
+ )
346
+ handle.write(chunk)
347
+ os.replace(part, dest)
348
+ except BaseException:
349
+ part.unlink(missing_ok=True)
350
+ raise
351
+
352
+
353
+ def fetch_bytes(
354
+ url: str,
355
+ *,
356
+ timeout: float = DEFAULT_TIMEOUT,
357
+ headers: dict[str, str] | None = None,
358
+ ) -> bytes:
359
+ """Récupère un corps **binaire** en mémoire ; anti-SSRF + plafond manifeste.
360
+
361
+ Pour les ressources qui doivent être parsées depuis des **octets** (ex. ALTO
362
+ Gallica : lxml refuse un ``str`` portant une déclaration d'encodage XML).
363
+ """
364
+ try:
365
+ with _stream_validated(url, timeout=timeout, headers=headers) as response:
366
+ response.raise_for_status()
367
+ return _read_capped(response, MANIFEST_MAX_BYTES)
368
+ except httpx.HTTPStatusError as exc:
369
+ raise HttpFetchError(
370
+ f"statut HTTP {exc.response.status_code} sur {url!r}."
371
+ ) from exc
372
+ except httpx.HTTPError as exc:
373
+ # Échec de transport (connexion/timeout/protocole) : on l'enveloppe en
374
+ # HttpFetchError pour que les appelants n'aient qu'un type réseau à gérer
375
+ # (jamais une exception httpx brute qui échapperait au repli).
376
+ raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
377
+
378
+
379
+ def fetch_text(
380
+ url: str,
381
+ *,
382
+ timeout: float = DEFAULT_TIMEOUT,
383
+ headers: dict[str, str] | None = None,
384
+ ) -> str:
385
+ """Récupère un corps **texte** (UTF-8, erreurs remplacées) ; anti-SSRF + plafond.
386
+
387
+ Pour les endpoints qui ne servent pas du JSON (ex. catalogue HTR-United).
388
+ """
389
+ # Accept #15 : un corps distant peut contenir des octets non-UTF-8 isolés ;
390
+ # `errors="replace"` insère U+FFFD plutôt que d'échouer sur une donnée mal
391
+ # encodée (le contenu reste exploitable, pas une GT manuelle).
392
+ return fetch_bytes(url, timeout=timeout, headers=headers).decode(
393
+ "utf-8", errors="replace"
394
+ )
395
+
396
+
397
+ __all__ = [
398
+ "CorpusHttpError",
399
+ "HttpFetchError",
400
+ "IMAGE_MAX_BYTES",
401
+ "MANIFEST_MAX_BYTES",
402
+ "SsrfError",
403
+ "assert_public_url",
404
+ "download",
405
+ "fetch_bytes",
406
+ "fetch_json",
407
+ "fetch_text",
408
+ ]
cinoc/adapters/corpus/escriptorium.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Importeur de corpus **eScriptorium** (API REST) — couche 5.
2
+
3
+ Rôle : **localiser** les pages d'un document eScriptorium (auth par token →
4
+ liste paginée des *parts* + leur transcription) et retourner un type neutre. La
5
+ **matérialisation disque** (download image + écriture GT → ``CorpusSpec``) vit en
6
+ couche ``app`` (``app/corpus_import.py``).
7
+
8
+ Contrairement à IIIF, eScriptorium porte une **vérité-terrain** (la couche de
9
+ transcription choisie) → le corpus importé est **scorable** (CER/WER).
10
+
11
+ API consommée (lecture seule) :
12
+
13
+ - ``GET /api/documents/{pk}/parts/`` → pages (paginé) ;
14
+ - ``GET /api/documents/{pk}/parts/{pk}/transcriptions/`` → transcriptions.
15
+
16
+ Dette de la source **non reprise** : l'export distant de couche OCR, le listing
17
+ projets/connexion (surface morte ~40 %), et surtout le bug latent
18
+ ``Corpus(source=…)`` — ici la sortie est un ``CorpusSpec`` (``source`` va dans
19
+ ``metadata``), structurellement à l'abri.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from dataclasses import dataclass
26
+ from typing import Any
27
+ from urllib.parse import urljoin, urlsplit
28
+
29
+ from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_json
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ #: Couche de transcription par défaut (libellé eScriptorium usuel pour la GT).
34
+ DEFAULT_LAYER = "manual"
35
+ #: Borne de pagination (un document = au plus quelques milliers de pages).
36
+ _MAX_PAGES = 10_000
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class EScriptoriumPage:
41
+ """Une page (*part*) : son URL d'image absolue + son texte GT (peut être vide)."""
42
+
43
+ pk: int
44
+ image_url: str
45
+ gt_text: str
46
+ title: str
47
+
48
+
49
+ def _image_uri(item: dict[str, Any], base_url: str) -> str:
50
+ """Extrait l'URL d'image d'une *part* (champ ``image`` : str ou objet)."""
51
+ img = item.get("image")
52
+ if isinstance(img, dict):
53
+ img = img.get("uri") or img.get("url") or ""
54
+ if not isinstance(img, str) or not img:
55
+ return ""
56
+ return urljoin(base_url, img)
57
+
58
+
59
+ def _layer_name(transcription: dict[str, Any]) -> str:
60
+ nested = transcription.get("transcription")
61
+ if isinstance(nested, dict):
62
+ return str(nested.get("name", ""))
63
+ return str(transcription.get("name", ""))
64
+
65
+
66
+ def extract_gt_text(transcriptions: list[dict[str, Any]], layer: str) -> str:
67
+ """Texte GT de la couche ``layer`` (lignes jointes, ou ``content``). Pur.
68
+
69
+ ``layer`` vide → première transcription rencontrée. Renvoie ``""`` si la
70
+ couche demandée est absente.
71
+ """
72
+ for transcription in transcriptions:
73
+ if layer and _layer_name(transcription) != layer:
74
+ continue
75
+ lines = transcription.get("lines") or []
76
+ if lines:
77
+ return "\n".join(
78
+ str(line.get("content", ""))
79
+ for line in lines
80
+ if isinstance(line, dict) and line.get("content")
81
+ )
82
+ return str(transcription.get("content", "") or "")
83
+ return ""
84
+
85
+
86
+ class EScriptoriumImporter:
87
+ """Localise les pages d'un document eScriptorium (auth + API, **sans disque**)."""
88
+
89
+ name = "escriptorium"
90
+
91
+ def __init__(
92
+ self,
93
+ base_url: str,
94
+ token: str,
95
+ *,
96
+ layer: str = DEFAULT_LAYER,
97
+ timeout: float = DEFAULT_TIMEOUT,
98
+ ) -> None:
99
+ self._base_url = base_url.rstrip("/")
100
+ self._token = token
101
+ self._layer = layer
102
+ self._timeout = timeout
103
+
104
+ def _headers(self) -> dict[str, str]:
105
+ # Le token n'est jamais journalisé (cf. _http : erreurs sans en-têtes).
106
+ return {"Authorization": f"Token {self._token}", "Accept": "application/json"}
107
+
108
+ def _get(self, url: str) -> dict[str, Any]:
109
+ data = fetch_json(url, timeout=self._timeout, headers=self._headers())
110
+ if not isinstance(data, dict):
111
+ raise HttpFetchError(
112
+ f"réponse eScriptorium inattendue (objet JSON attendu) : {url!r}."
113
+ )
114
+ return data
115
+
116
+ def _paginate(self, path: str) -> list[dict[str, Any]]:
117
+ """Suit les liens ``next`` jusqu'à épuisement, **sans quitter l'hôte**.
118
+
119
+ Le ``next`` est contrôlé par le serveur : on **n'envoie jamais le jeton**
120
+ vers un hôte différent de ``base_url`` (un ``next`` cross-hôte est ignoré).
121
+ """
122
+ base_host = urlsplit(self._base_url).hostname
123
+ results: list[dict[str, Any]] = []
124
+ url: str | None = f"{self._base_url}/api/{path.lstrip('/')}"
125
+ for _ in range(_MAX_PAGES):
126
+ if not url:
127
+ break
128
+ data = self._get(url)
129
+ page = data.get("results")
130
+ results.extend(page if isinstance(page, list) else [])
131
+ nxt = data.get("next")
132
+ if not (isinstance(nxt, str) and nxt):
133
+ break
134
+ if urlsplit(nxt).hostname != base_host:
135
+ logger.warning(
136
+ "[escriptorium] lien 'next' hors hôte ignoré (pas de jeton "
137
+ "envoyé à un tiers) : %s",
138
+ nxt,
139
+ )
140
+ break
141
+ url = nxt
142
+ return results
143
+
144
+ def _transcriptions(self, doc_pk: int, part_pk: int) -> list[dict[str, Any]]:
145
+ url = f"{self._base_url}/api/documents/{doc_pk}/parts/{part_pk}/transcriptions/"
146
+ data = fetch_json(url, timeout=self._timeout, headers=self._headers())
147
+ if isinstance(data, list):
148
+ return [t for t in data if isinstance(t, dict)]
149
+ if isinstance(data, dict):
150
+ page = data.get("results")
151
+ if isinstance(page, list):
152
+ return [t for t in page if isinstance(t, dict)]
153
+ return []
154
+
155
+ def fetch_pages(self, doc_pk: int) -> tuple[EScriptoriumPage, ...]:
156
+ pages: list[EScriptoriumPage] = []
157
+ for item in self._paginate(f"documents/{doc_pk}/parts/"):
158
+ pk = item.get("pk")
159
+ if not isinstance(pk, int):
160
+ # Accept #14 : une *part* sans pk entier (schéma inattendu) est
161
+ # ignorée — on ne devine pas un identifiant. Avertir, pas planter.
162
+ logger.warning(
163
+ "[escriptorium] part sans 'pk' entier ignorée (doc %s) : %r",
164
+ doc_pk,
165
+ item.get("pk"),
166
+ )
167
+ continue
168
+ transcriptions = self._transcriptions(doc_pk, pk)
169
+ pages.append(
170
+ EScriptoriumPage(
171
+ pk=pk,
172
+ image_url=_image_uri(item, self._base_url),
173
+ gt_text=extract_gt_text(transcriptions, self._layer),
174
+ title=str(item.get("title", "") or f"part {pk}"),
175
+ )
176
+ )
177
+ return tuple(pages)
178
+
179
+
180
+ __all__ = [
181
+ "DEFAULT_LAYER",
182
+ "EScriptoriumImporter",
183
+ "EScriptoriumPage",
184
+ "extract_gt_text",
185
+ ]
cinoc/adapters/corpus/gallica.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Importeur de corpus **Gallica** (BnF) — couche 5.
2
+
3
+ Gallica expose ses images via un **manifeste IIIF** (réutilisé tel quel par
4
+ ``IIIFImporter``) et son OCR par page via l'endpoint **ALTO** officiel
5
+ ``RequestDigitalElement``. Cet adapter ne porte que la logique propre à Gallica :
6
+ normalisation de l'**ARK**, URL du manifeste IIIF, et récupération de l'OCR d'une
7
+ page (ALTO → texte en ordre de lecture).
8
+
9
+ Choix : **ALTO** plutôt que ``texteBrut``. L'endpoint ``f{n}.texteBrut`` est
10
+ désormais protégé par un challenge anti-bot (ALTCHA) en accès automatisé, donc
11
+ inutilisable en import ; ``RequestDigitalElement?...&E=ALTO`` reste accessible et
12
+ plus riche (on en extrait le texte via le parseur ALTO de la couche 2).
13
+
14
+ Bug latent de la source **corrigé** : la numérotation page→OCR ne passe plus par
15
+ une indirection ``selected_indices[i]+1`` (source d'un décalage) — c'est la
16
+ **vue Gallica** lue dans l'URL d'image IIIF (``/f{n}/``) qui *est* le ``Deb`` de
17
+ la requête ALTO. Le mapping est fait par le service ``app`` et prouvé par test.
18
+
19
+ ⚠️ L'OCR Gallica **n'est pas une vérité-terrain manuelle** : c'est la transcription
20
+ automatique de la BnF, utilisable comme *référence* (baseline) à condition de
21
+ l'étiqueter (``gt_source=gallica_ocr`` dans les métadonnées du corpus).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import re
28
+ from urllib.parse import urlencode
29
+
30
+ from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_bytes
31
+ from cinoc.domain.errors import CinocError, FormatError
32
+ from cinoc.formats.alto import (
33
+ AltoBlock,
34
+ AltoComposedBlock,
35
+ AltoDocument,
36
+ AltoTextBlock,
37
+ parse_alto,
38
+ )
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ #: Hôte Gallica de production (surchargeable pour les tests transport).
43
+ GALLICA_BASE = "https://gallica.bnf.fr"
44
+
45
+ #: ARK normalisé : ``naID/identifiant`` (ex. ``12148/btv1b8453561w``).
46
+ _ARK_RE = re.compile(r"^[0-9]+/[A-Za-z0-9_.\-]+$")
47
+
48
+ #: Numéro de **vue** Gallica dans une URL d'image IIIF (``…/ark:/12148/X/f3/…``).
49
+ _VUE_RE = re.compile(r"/f(\d+)/")
50
+
51
+
52
+ class GallicaArkError(CinocError):
53
+ """ARK Gallica malformé."""
54
+
55
+
56
+ def normalize_ark(ark: str) -> str:
57
+ """Normalise un ARK (``ark:/12148/x`` | ``12148/x`` → ``12148/x``). Pur."""
58
+ value = ark.strip()
59
+ if value.startswith("ark:/"):
60
+ value = value[len("ark:/"):]
61
+ value = value.strip("/")
62
+ if not _ARK_RE.match(value):
63
+ raise GallicaArkError(
64
+ f"ARK invalide : {ark!r}. Forme attendue 'naID/identifiant' "
65
+ "(ex. '12148/btv1b8453561w')."
66
+ )
67
+ return value
68
+
69
+
70
+ def vue_number(image_url: str) -> int | None:
71
+ """Numéro de vue Gallica extrait d'une URL d'image (``/f{n}/``), sinon ``None``.
72
+
73
+ C'est la source **autoritaire** pour apparier une image à son OCR : se fier à
74
+ la position dans la liste d'images est faux dès qu'un canvas sans image est
75
+ sauté par le parseur IIIF (décalage de page).
76
+ """
77
+ match = _VUE_RE.search(image_url)
78
+ return int(match.group(1)) if match else None
79
+
80
+
81
+ def _block_lines(blocks: tuple[AltoBlock, ...], out: list[str]) -> None:
82
+ """Aplatit les ``<TextBlock>`` (récursif sur les ``<ComposedBlock>``) en lignes."""
83
+ for block in blocks:
84
+ if isinstance(block, AltoTextBlock):
85
+ for line in block.lines:
86
+ text = " ".join(s.content for s in line.strings if s.content)
87
+ if text:
88
+ out.append(text)
89
+ elif isinstance(block, AltoComposedBlock):
90
+ _block_lines(block.blocks, out)
91
+
92
+
93
+ def alto_to_text(document: AltoDocument) -> str:
94
+ """Texte d'un ALTO en **ordre de lecture** : lignes (``String/@CONTENT``) jointes.
95
+
96
+ Pas de dé-césure (le ``texteBrut`` historique n'en faisait pas non plus) : on
97
+ rend le contenu brut, ligne par ligne.
98
+ """
99
+ lines: list[str] = []
100
+ for page in document.pages:
101
+ _block_lines(page.blocks, lines)
102
+ return "\n".join(lines)
103
+
104
+
105
+ class GallicaImporter:
106
+ """Localise un document Gallica : URL de manifeste IIIF + OCR ALTO par page."""
107
+
108
+ name = "gallica"
109
+
110
+ def __init__(
111
+ self,
112
+ ark: str,
113
+ *,
114
+ base_url: str = GALLICA_BASE,
115
+ timeout: float = DEFAULT_TIMEOUT,
116
+ ) -> None:
117
+ self.ark = normalize_ark(ark)
118
+ self._base_url = base_url.rstrip("/")
119
+ self._timeout = timeout
120
+
121
+ @property
122
+ def _identifier(self) -> str:
123
+ """Identifiant de la numérisation (ARK sans le naID) — param ``O`` de l'ALTO."""
124
+ return self.ark.split("/", 1)[1]
125
+
126
+ @property
127
+ def manifest_url(self) -> str:
128
+ return f"{self._base_url}/iiif/ark:/{self.ark}/manifest.json"
129
+
130
+ def alto_url(self, page: int) -> str:
131
+ """URL ALTO officielle d'une vue (``RequestDigitalElement``)."""
132
+ query = urlencode({"O": self._identifier, "E": "ALTO", "Deb": page})
133
+ return f"{self._base_url}/RequestDigitalElement?{query}"
134
+
135
+ def fetch_ocr_text(self, page: int) -> str:
136
+ """OCR d'une vue (1-based) via ALTO → texte. ``""`` si absent ou illisible."""
137
+ try:
138
+ data = fetch_bytes(self.alto_url(page), timeout=self._timeout)
139
+ except HttpFetchError as exc:
140
+ logger.warning(
141
+ "[gallica] ALTO indisponible pour %s f%d : %s", self.ark, page, exc
142
+ )
143
+ return ""
144
+ try:
145
+ return alto_to_text(parse_alto(data))
146
+ except FormatError as exc: # ALTO malformé ou XML invalide
147
+ logger.warning(
148
+ "[gallica] ALTO illisible pour %s f%d : %s", self.ark, page, exc
149
+ )
150
+ return ""
151
+
152
+
153
+ __all__ = [
154
+ "GALLICA_BASE",
155
+ "GallicaArkError",
156
+ "GallicaImporter",
157
+ "alto_to_text",
158
+ "normalize_ark",
159
+ "vue_number",
160
+ ]
cinoc/adapters/corpus/htr_united.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Catalogue **HTR-United** (découverte de corpus GT) — couche 5.
2
+
3
+ HTR-United publie un index communautaire de corpus de vérité-terrain HTR/OCR sous
4
+ forme d'un fichier YAML versionné sur GitHub. Cet adapter **découvre** (télécharge
5
+ l'index + recherche), il ne **matérialise pas** un corpus (les entrées pointent
6
+ vers des dépôts ALTO/PAGE — matérialisation = incrémentale, ultérieure).
7
+
8
+ Repli **hors-ligne** assumé : si l'index distant est injoignable ou illisible, on
9
+ sert un **socle de démonstration** intégré (petit, pas un dump) avec
10
+ ``source="demo"`` / ``is_demo=True`` — l'appelant sait que ce n'est pas le
11
+ catalogue à jour (alimente la future UI de sélection de corpus).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass
18
+
19
+ import yaml
20
+
21
+ from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, CorpusHttpError, fetch_text
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ #: Index communautaire (YAML brut sur GitHub).
26
+ CATALOGUE_URL = (
27
+ "https://raw.githubusercontent.com/HTR-United/htr-united/master/htr-united.yml"
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class HTRUnitedEntry:
33
+ """Une entrée du catalogue (métadonnées de découverte, pas le corpus)."""
34
+
35
+ id: str
36
+ title: str
37
+ url: str
38
+ description: str
39
+ languages: tuple[str, ...]
40
+
41
+
42
+ #: Socle de démonstration minimal (corpus HTR-United réels) — repli hors-ligne.
43
+ _DEMO_ENTRIES: tuple[HTRUnitedEntry, ...] = (
44
+ HTRUnitedEntry(
45
+ id="cremma-medieval",
46
+ title="CREMMA Medieval",
47
+ url="https://github.com/HTR-United/cremma-medieval",
48
+ description="Manuscrits médiévaux français et latins.",
49
+ languages=("fr", "la"),
50
+ ),
51
+ HTRUnitedEntry(
52
+ id="lectaurep-repertoires",
53
+ title="LECTAUREP Répertoires",
54
+ url="https://github.com/HTR-United/lectaurep-repertoires",
55
+ description="Répertoires de notaires manuscrits (XIXe-XXe).",
56
+ languages=("fr",),
57
+ ),
58
+ HTRUnitedEntry(
59
+ id="caroline-minuscule",
60
+ title="Caroline Minuscule",
61
+ url="https://github.com/HTR-United/CemmaCarolineMinuscule",
62
+ description="Minuscule caroline latine.",
63
+ languages=("la",),
64
+ ),
65
+ )
66
+
67
+
68
+ def _as_languages(raw: object) -> tuple[str, ...]:
69
+ """Normalise un champ langue (str, liste de str, ou liste de dicts)."""
70
+ if isinstance(raw, str):
71
+ return (raw,)
72
+ if isinstance(raw, list):
73
+ out: list[str] = []
74
+ for item in raw:
75
+ if isinstance(item, str):
76
+ out.append(item)
77
+ elif isinstance(item, dict):
78
+ value = item.get("iso") or item.get("lang") or item.get("label")
79
+ if value:
80
+ out.append(str(value))
81
+ return tuple(out)
82
+ return ()
83
+
84
+
85
+ def _entry_from_dict(item: dict[str, object]) -> HTRUnitedEntry | None:
86
+ url = str(item.get("url", "") or "")
87
+ title = str(item.get("title", "") or "")
88
+ if not url and not title:
89
+ return None
90
+ entry_id = str(item.get("id") or "") or url.rstrip("/").rsplit("/", 1)[-1] or title
91
+ return HTRUnitedEntry(
92
+ id=entry_id,
93
+ title=title or entry_id,
94
+ url=url,
95
+ description=str(item.get("description", "") or ""),
96
+ languages=_as_languages(item.get("language")),
97
+ )
98
+
99
+
100
+ def parse_catalogue(raw_yaml: str) -> tuple[HTRUnitedEntry, ...]:
101
+ """Parse l'index YAML HTR-United → entrées. Pur (tolérant au schéma)."""
102
+ data = yaml.safe_load(raw_yaml)
103
+ if isinstance(data, dict):
104
+ for key in ("data", "projects", "catalog", "catalogue"):
105
+ if isinstance(data.get(key), list):
106
+ data = data[key]
107
+ break
108
+ if not isinstance(data, list):
109
+ return ()
110
+ entries = [
111
+ _entry_from_dict(item) for item in data if isinstance(item, dict)
112
+ ]
113
+ return tuple(e for e in entries if e is not None)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class HTRUnitedCatalogue:
118
+ """Index HTR-United chargé, avec sa provenance (``remote`` | ``demo``)."""
119
+
120
+ entries: tuple[HTRUnitedEntry, ...]
121
+ source: str
122
+
123
+ @property
124
+ def is_demo(self) -> bool:
125
+ return self.source == "demo"
126
+
127
+ @classmethod
128
+ def from_demo(cls) -> HTRUnitedCatalogue:
129
+ return cls(entries=_DEMO_ENTRIES, source="demo")
130
+
131
+ def search(
132
+ self, query: str = "", language: str | None = None
133
+ ) -> tuple[HTRUnitedEntry, ...]:
134
+ results = self.entries
135
+ if query:
136
+ q = query.lower()
137
+ results = tuple(
138
+ e
139
+ for e in results
140
+ if q in e.title.lower()
141
+ or q in e.description.lower()
142
+ or q in e.id.lower()
143
+ or any(q in lg.lower() for lg in e.languages)
144
+ )
145
+ if language:
146
+ lang = language.lower()
147
+ results = tuple(
148
+ e for e in results if any(lang in lg.lower() for lg in e.languages)
149
+ )
150
+ return results
151
+
152
+
153
+ def fetch_catalogue(
154
+ *, catalogue_url: str = CATALOGUE_URL, timeout: float = DEFAULT_TIMEOUT
155
+ ) -> HTRUnitedCatalogue:
156
+ """Télécharge l'index HTR-United ; **repli démo** si indisponible/illisible."""
157
+ try:
158
+ raw = fetch_text(catalogue_url, timeout=timeout)
159
+ entries = parse_catalogue(raw)
160
+ except (CorpusHttpError, yaml.YAMLError) as exc:
161
+ logger.warning(
162
+ "[htr_united] index distant indisponible (%s) — repli démo : %s",
163
+ catalogue_url,
164
+ exc,
165
+ )
166
+ return HTRUnitedCatalogue.from_demo()
167
+ return HTRUnitedCatalogue(entries=entries, source="remote")
168
+
169
+
170
+ __all__ = [
171
+ "CATALOGUE_URL",
172
+ "HTRUnitedCatalogue",
173
+ "HTRUnitedEntry",
174
+ "fetch_catalogue",
175
+ "parse_catalogue",
176
+ ]
cinoc/adapters/corpus/huggingface.py ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Découverte de datasets HTR/OCR sur **HuggingFace Hub** — couche 5.
2
+
3
+ Recherche (découverte), pas matérialisation : on liste des datasets candidats
4
+ (socle de **référence** intégré + API publique du Hub). Le téléchargement effectif
5
+ d'un dataset (lib ``datasets``, dépendance lourde) est un extra **ultérieur**
6
+ (`cinoc[huggingface]`), hors de cette tranche.
7
+
8
+ L'appel API est **best-effort** : s'il échoue, on renvoie le socle de référence
9
+ (``source="reference"``) en journalisant — l'utilisateur n'est jamais bloqué, et
10
+ sait d'où vient chaque résultat (``source`` : ``reference`` | ``api``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from collections.abc import Callable, Iterable, Iterator
17
+ from dataclasses import dataclass
18
+ from pathlib import Path, PurePosixPath
19
+ from urllib.parse import urlencode
20
+
21
+ from cinoc.adapters.corpus._http import (
22
+ DEFAULT_TIMEOUT,
23
+ IMAGE_MAX_BYTES,
24
+ CorpusHttpError,
25
+ fetch_json,
26
+ )
27
+ from cinoc.domain.errors import CinocError
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ HF_API_BASE = "https://huggingface.co/api"
32
+
33
+ #: Tag de convention apposé aux datasets **curés Cinoc** (carte HF) : il rend un
34
+ #: dataset de référence **découvrable** sur un compte sans coller son ``repo_id``.
35
+ #: Émis par ``app.dataset_standardize`` (front-matter), filtré par ``discover_curated``.
36
+ CURATED_DATASET_TAG = "cinoc-corpus"
37
+
38
+ #: Convention Cinoc (cf. ``docs/corpus_huggingface.md``) : un dataset importable
39
+ #: porte **au minimum** une colonne image et une colonne vérité-terrain. La
40
+ #: segmentation est une extension *future* (non requise ici).
41
+ CINOC_IMAGE_COLUMN = "image"
42
+ CINOC_GT_COLUMN = "ground_truth"
43
+
44
+ #: Extensions image reconnues (sinon ``.jpg`` par défaut) pour nommer le fichier.
45
+ _IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".jp2", ".webp"})
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class HuggingFaceDataset:
50
+ """Un dataset candidat (métadonnées de découverte)."""
51
+
52
+ dataset_id: str
53
+ title: str
54
+ description: str
55
+ languages: tuple[str, ...]
56
+ downloads: int
57
+ source: str # "reference" | "api"
58
+
59
+ @property
60
+ def hf_url(self) -> str:
61
+ return f"https://huggingface.co/datasets/{self.dataset_id}"
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class CuratedDatasetRef:
66
+ """Un dataset **curé Cinoc** découvert sur un compte HF (tag de convention).
67
+
68
+ Métadonnées d'affichage **et** d'import en un clic : ``repo_id`` (+ ``revision``
69
+ si l'API la fournit, pour épingler la repro) alimentent directement
70
+ ``import_curated_hf_corpus``."""
71
+
72
+ repo_id: str
73
+ title: str
74
+ revision: str | None
75
+ last_modified: str
76
+
77
+
78
+ def _ref(
79
+ dataset_id: str, title: str, desc: str, langs: tuple[str, ...], dl: int
80
+ ) -> HuggingFaceDataset:
81
+ return HuggingFaceDataset(
82
+ dataset_id=dataset_id,
83
+ title=title,
84
+ description=desc,
85
+ languages=langs,
86
+ downloads=dl,
87
+ source="reference",
88
+ )
89
+
90
+
91
+ #: Socle de référence minimal (datasets HTR/OCR patrimoniaux connus).
92
+ _REFERENCE_DATASETS: tuple[HuggingFaceDataset, ...] = (
93
+ _ref(
94
+ "Teklia/NorHand", "NorHand", "Écriture manuscrite norvégienne.", ("no",), 1200
95
+ ),
96
+ _ref(
97
+ "Teklia/IAM-line",
98
+ "IAM (lignes)",
99
+ "Référence anglaise manuscrite.",
100
+ ("en",),
101
+ 8400,
102
+ ),
103
+ _ref(
104
+ "bnf/gallica-presse-xix",
105
+ "Gallica Presse XIXe",
106
+ "Journaux numérisés XIXe (OCR + images).",
107
+ ("fr",),
108
+ 15200,
109
+ ),
110
+ )
111
+
112
+
113
+ def _matches(ds: HuggingFaceDataset, query: str, language: str | None) -> bool:
114
+ if query:
115
+ q = query.lower()
116
+ if not (
117
+ q in ds.title.lower()
118
+ or q in ds.description.lower()
119
+ or q in ds.dataset_id.lower()
120
+ or any(q in lg.lower() for lg in ds.languages)
121
+ ):
122
+ return False
123
+ if language and not any(language.lower() in lg.lower() for lg in ds.languages):
124
+ return False
125
+ return True
126
+
127
+
128
+ def search_reference(
129
+ query: str = "", language: str | None = None
130
+ ) -> tuple[HuggingFaceDataset, ...]:
131
+ """Filtre le socle de référence intégré. Pur (sans réseau)."""
132
+ return tuple(ds for ds in _REFERENCE_DATASETS if _matches(ds, query, language))
133
+
134
+
135
+ def _parse_api(payload: object) -> tuple[HuggingFaceDataset, ...]:
136
+ """Mappe la réponse de l'API HF (liste de dicts) → datasets. Pur."""
137
+ if not isinstance(payload, list):
138
+ return ()
139
+ out: list[HuggingFaceDataset] = []
140
+ for item in payload:
141
+ if not isinstance(item, dict):
142
+ continue
143
+ dataset_id = str(item.get("id", "") or "")
144
+ if not dataset_id:
145
+ continue
146
+ downloads = item.get("downloads", 0)
147
+ out.append(
148
+ HuggingFaceDataset(
149
+ dataset_id=dataset_id,
150
+ title=dataset_id,
151
+ description="",
152
+ languages=(),
153
+ downloads=int(downloads) if isinstance(downloads, int) else 0,
154
+ source="api",
155
+ )
156
+ )
157
+ return tuple(out)
158
+
159
+
160
+ class HuggingFaceCatalogue:
161
+ """Recherche de datasets : socle de référence + API publique (best-effort)."""
162
+
163
+ name = "huggingface"
164
+
165
+ def __init__(
166
+ self, *, api_base: str = HF_API_BASE, timeout: float = DEFAULT_TIMEOUT
167
+ ) -> None:
168
+ self._api_base = api_base.rstrip("/")
169
+ self._timeout = timeout
170
+
171
+ def _fetch_api(
172
+ self, query: str, language: str | None, limit: int
173
+ ) -> tuple[HuggingFaceDataset, ...]:
174
+ params: dict[str, str] = {
175
+ "task_categories": "image-to-text",
176
+ "limit": str(min(limit, 50)),
177
+ }
178
+ if query:
179
+ params["search"] = query
180
+ if language:
181
+ params["language"] = language
182
+ url = f"{self._api_base}/datasets?{urlencode(params)}"
183
+ return _parse_api(fetch_json(url, timeout=self._timeout))
184
+
185
+ def search(
186
+ self,
187
+ query: str = "",
188
+ language: str | None = None,
189
+ *,
190
+ limit: int = 20,
191
+ use_reference: bool = True,
192
+ include_api: bool = True,
193
+ ) -> tuple[HuggingFaceDataset, ...]:
194
+ results: list[HuggingFaceDataset] = []
195
+ if use_reference:
196
+ results.extend(search_reference(query, language))
197
+ if include_api:
198
+ seen = {ds.dataset_id for ds in results}
199
+ try:
200
+ for ds in self._fetch_api(query, language, limit):
201
+ if ds.dataset_id not in seen:
202
+ results.append(ds)
203
+ seen.add(ds.dataset_id)
204
+ except CorpusHttpError as exc:
205
+ logger.warning(
206
+ "[huggingface] recherche API indisponible — socle de référence "
207
+ "seul : %s",
208
+ exc,
209
+ )
210
+ # Accept #9 : socle de référence d'abord, API ensuite, puis troncature
211
+ # finale à `limit` — la référence (curée) prime sur l'API si l'union
212
+ # dépasse. Choix de découverte (display), pas de classement par score.
213
+ return tuple(results[:limit])
214
+
215
+
216
+ class HuggingFaceConventionError(CinocError):
217
+ """Le dataset ne respecte pas la **convention Cinoc** (colonnes attendues)."""
218
+
219
+
220
+ class HuggingFaceUnavailableError(CinocError):
221
+ """La lib ``datasets`` (extra ``[huggingface]``) n'est pas installée."""
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class HFPage:
226
+ """Une page **streamée** : octets image encodés + extension + texte GT.
227
+
228
+ Type neutre à la frontière adapter→app : l'image est livrée en **octets**
229
+ (jamais un objet PIL) pour que la matérialisation disque (couche 6) n'ait
230
+ aucune dépendance image.
231
+ """
232
+
233
+ image_bytes: bytes
234
+ image_ext: str
235
+ gt_text: str
236
+
237
+
238
+ #: Charge un dataset en streaming → itérable de lignes. Injectable (test).
239
+ HFLoader = Callable[[str, str], Iterable[object]]
240
+
241
+
242
+ def _default_loader(dataset_id: str, split: str) -> Iterable[object]:
243
+ """Charge le dataset HF **en streaming** (jamais de snapshot local complet).
244
+
245
+ Import **paresseux** de ``datasets`` (extra ``[huggingface]``) : sans l'extra,
246
+ lève ``HuggingFaceUnavailableError`` (message actionnable). Les images sont
247
+ castées en ``Image(decode=False)`` → octets bruts (pas de décodage PIL).
248
+ """
249
+ try:
250
+ from datasets import Image, load_dataset # type: ignore[import-not-found]
251
+ except ImportError as exc:
252
+ raise HuggingFaceUnavailableError(
253
+ "import HuggingFace indisponible : installer 'cinoc[huggingface]' "
254
+ "(lib 'datasets')."
255
+ ) from exc
256
+ dataset = load_dataset(dataset_id, split=split, streaming=True)
257
+ features = getattr(dataset, "features", None) or {}
258
+ if CINOC_IMAGE_COLUMN in features:
259
+ dataset = dataset.cast_column(CINOC_IMAGE_COLUMN, Image(decode=False))
260
+ return dataset # type: ignore[no-any-return]
261
+
262
+
263
+ def _ext_from_path(path: object) -> str:
264
+ if isinstance(path, str) and path:
265
+ suffix = PurePosixPath(path).suffix.lower()
266
+ if suffix in _IMAGE_EXTS:
267
+ return suffix
268
+ return ".jpg"
269
+
270
+
271
+ def _extract_image(value: object) -> tuple[bytes, str]:
272
+ """Octets image + extension depuis une cellule ``image`` (``decode=False``)."""
273
+ if isinstance(value, dict):
274
+ raw = value.get("bytes")
275
+ if isinstance(raw, bytes) and raw:
276
+ return raw, _ext_from_path(value.get("path"))
277
+ raise HuggingFaceConventionError(
278
+ f"colonne {CINOC_IMAGE_COLUMN!r} : octets image absents (le dataset "
279
+ "doit stocker l'image en octets ; cf. convention Cinoc)."
280
+ )
281
+
282
+
283
+ def _validate_columns(row: dict[str, object]) -> None:
284
+ missing = [c for c in (CINOC_IMAGE_COLUMN, CINOC_GT_COLUMN) if c not in row]
285
+ if missing:
286
+ raise HuggingFaceConventionError(
287
+ f"dataset non conforme à la convention Cinoc : colonnes manquantes "
288
+ f"{missing} (attendu : {CINOC_IMAGE_COLUMN!r} + {CINOC_GT_COLUMN!r})."
289
+ )
290
+
291
+
292
+ def stream_pages(
293
+ dataset_id: str,
294
+ *,
295
+ split: str = "train",
296
+ limit: int | None = None,
297
+ loader: HFLoader | None = None,
298
+ ) -> Iterator[HFPage]:
299
+ """Streame les pages d'un dataset HF conforme à la **convention Cinoc**.
300
+
301
+ Page-par-page (``streaming=True`` côté ``datasets``) : on ne télécharge jamais
302
+ le snapshot complet ; ``limit`` borne le nombre de pages lues. La conformité
303
+ (colonnes ``image`` + ``ground_truth``) est validée sur la **1ʳᵉ ligne**. Une
304
+ image dépassant ``IMAGE_MAX_BYTES`` est **ignorée** (avertie), pas fatale.
305
+
306
+ ``loader`` (``(dataset_id, split) → itérable de lignes``) est injectable pour
307
+ tester sans la lib ``datasets`` ; défaut = streaming réel.
308
+ """
309
+ load = loader or _default_loader
310
+ validated = False
311
+ count = 0
312
+ for row in load(dataset_id, split):
313
+ if not isinstance(row, dict):
314
+ continue
315
+ if not validated:
316
+ _validate_columns(row)
317
+ validated = True
318
+ image_bytes, ext = _extract_image(row[CINOC_IMAGE_COLUMN])
319
+ if len(image_bytes) > IMAGE_MAX_BYTES:
320
+ logger.warning(
321
+ "[huggingface] image > %d octets ignorée (dataset %s).",
322
+ IMAGE_MAX_BYTES,
323
+ dataset_id,
324
+ )
325
+ continue
326
+ gt_value = row.get(CINOC_GT_COLUMN)
327
+ gt_text = gt_value if isinstance(gt_value, str) else ""
328
+ yield HFPage(image_bytes=image_bytes, image_ext=ext, gt_text=gt_text)
329
+ count += 1
330
+ if limit is not None and count >= limit:
331
+ return
332
+
333
+
334
+ #: Récupère un JSON depuis une URL. Injectable → ``discover_curated`` se teste
335
+ #: sans réseau (le défaut tape l'API HF via ``fetch_json``).
336
+ CuratedFetcher = Callable[[str], object]
337
+
338
+
339
+ def _parse_curated(payload: object, *, tag: str) -> tuple[CuratedDatasetRef, ...]:
340
+ """Réponse ``/api/datasets`` (liste de dicts) → datasets curés. Pure.
341
+
342
+ Re-filtre **défensivement** sur le tag (si la liste porte ``tags``) : on ne
343
+ présente que de vrais layouts Cinoc, jamais un dataset quelconque du compte."""
344
+ if not isinstance(payload, list):
345
+ return ()
346
+ out: list[CuratedDatasetRef] = []
347
+ for item in payload:
348
+ if not isinstance(item, dict):
349
+ continue
350
+ repo_id = str(item.get("id", "") or "")
351
+ if not repo_id:
352
+ continue
353
+ tags = item.get("tags")
354
+ if isinstance(tags, list) and tag not in tags:
355
+ continue
356
+ sha = item.get("sha")
357
+ out.append(
358
+ CuratedDatasetRef(
359
+ repo_id=repo_id,
360
+ title=str(item.get("id", repo_id)),
361
+ revision=str(sha) if isinstance(sha, str) and sha else None,
362
+ last_modified=str(item.get("lastModified", "") or ""),
363
+ )
364
+ )
365
+ return tuple(out)
366
+
367
+
368
+ def whoami(
369
+ token: str,
370
+ *,
371
+ api_base: str = HF_API_BASE,
372
+ timeout: float = DEFAULT_TIMEOUT,
373
+ fetcher: Callable[[str], object] | None = None,
374
+ ) -> str | None:
375
+ """Handle du compte HF derrière un ``token`` (``/api/whoami-v2``). Best-effort.
376
+
377
+ Sert à faire **apparaître automatiquement** les datasets curés de l'opérateur
378
+ sans qu'il colle son identifiant : on résout son handle depuis sa clé. Échec
379
+ réseau / token invalide → ``None`` (repli sur ``CINOC_HF_AUTHOR`` ou l'import
380
+ manuel). Le token n'est jamais journalisé (cf. ``_http``)."""
381
+ if not token:
382
+ return None
383
+ get = fetcher or (
384
+ lambda url: fetch_json(
385
+ url, timeout=timeout, headers={"Authorization": f"Bearer {token}"}
386
+ )
387
+ )
388
+ try:
389
+ payload = get(f"{api_base.rstrip('/')}/whoami-v2")
390
+ except CorpusHttpError as exc:
391
+ logger.warning("[huggingface] whoami indisponible : %s", exc)
392
+ return None
393
+ if isinstance(payload, dict):
394
+ name = payload.get("name")
395
+ if isinstance(name, str) and name.strip():
396
+ return name.strip()
397
+ return None
398
+
399
+
400
+ def discover_curated(
401
+ author: str,
402
+ *,
403
+ tag: str = CURATED_DATASET_TAG,
404
+ api_base: str = HF_API_BASE,
405
+ timeout: float = DEFAULT_TIMEOUT,
406
+ fetcher: CuratedFetcher | None = None,
407
+ ) -> tuple[CuratedDatasetRef, ...]:
408
+ """Liste les datasets **curés Cinoc** d'un compte HF (``author`` + ``tag``).
409
+
410
+ Best-effort : API injoignable → ``()`` en journalisant (l'UI retombe sur
411
+ l'import manuel par ``repo_id``, jamais un blocage). C'est ce qui fait
412
+ « apparaître » un dataset publié sans coller son identifiant : on filtre le
413
+ compte par le tag de convention."""
414
+ if not author:
415
+ return ()
416
+ get = fetcher or (lambda url: fetch_json(url, timeout=timeout))
417
+ params = {"author": author, "filter": tag, "full": "true", "limit": "100"}
418
+ url = f"{api_base.rstrip('/')}/datasets?{urlencode(params)}"
419
+ try:
420
+ payload = get(url)
421
+ except CorpusHttpError as exc:
422
+ logger.warning(
423
+ "[huggingface] découverte curée indisponible (compte %s) : %s",
424
+ author,
425
+ exc,
426
+ )
427
+ return ()
428
+ return _parse_curated(payload, tag=tag)
429
+
430
+
431
+ def snapshot_curated_layout(
432
+ repo_id: str, dest: Path, revision: str | None
433
+ ) -> Path: # pragma: no cover -- réseau réel (test 'live')
434
+ """Snapshot **partiel** d'un dataset curé Cinoc publié sur HF.
435
+
436
+ Ne rapatrie que ``corpus.json`` + ``ground_truth/`` (scorables en local) ; les
437
+ images restent des **références distantes** : le dataset embarque un layout
438
+ **IIIF statique** (Image API 3 level0, cf. ``app.dataset_standardize``) servi
439
+ par la racine ``resolve`` HF épinglée à la révision — l'URL est donc un lien
440
+ ``huggingface.co`` dont le chemin suit la syntaxe IIIF. Le rapport les affiche
441
+ (saveur « liens », D-094) et l'orchestrateur les **matérialise au run** (fetch
442
+ durci) pour les moteurs. ``huggingface_hub`` (extra ``[huggingface]``) est
443
+ importé paresseusement → sans l'extra, ``HuggingFaceUnavailableError``
444
+ actionnable ; dépôt/révision injoignable → ``CorpusHttpError`` (→ 422 web).
445
+ """
446
+ try:
447
+ from huggingface_hub import snapshot_download # type: ignore[import-not-found]
448
+ from huggingface_hub.utils import ( # type: ignore[import-not-found]
449
+ HfHubHTTPError,
450
+ )
451
+ except ImportError as exc:
452
+ raise HuggingFaceUnavailableError(
453
+ "import curé HF indisponible : installer 'cinoc[huggingface]' "
454
+ "(lib 'huggingface_hub')."
455
+ ) from exc
456
+ try:
457
+ local = snapshot_download(
458
+ repo_id,
459
+ repo_type="dataset",
460
+ revision=revision,
461
+ allow_patterns=["corpus.json", "ground_truth/*"],
462
+ local_dir=str(dest),
463
+ )
464
+ except HfHubHTTPError as exc:
465
+ raise CorpusHttpError(
466
+ f"dataset curé HF {repo_id!r} injoignable : {exc}"
467
+ ) from exc
468
+ return Path(local)
469
+
470
+
471
+ __all__ = [
472
+ "HF_API_BASE",
473
+ "CINOC_GT_COLUMN",
474
+ "CINOC_IMAGE_COLUMN",
475
+ "CURATED_DATASET_TAG",
476
+ "CuratedDatasetRef",
477
+ "HFPage",
478
+ "HuggingFaceCatalogue",
479
+ "HuggingFaceConventionError",
480
+ "HuggingFaceDataset",
481
+ "HuggingFaceUnavailableError",
482
+ "discover_curated",
483
+ "search_reference",
484
+ "snapshot_curated_layout",
485
+ "stream_pages",
486
+ "whoami",
487
+ ]
cinoc/adapters/corpus/iiif.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Importeur de corpus **IIIF** (Presentation API v2 & v3) — couche 5.
2
+
3
+ Rôle : **localiser** les pages d'un manifeste (parsing du schéma → URLs d'image).
4
+ La **matérialisation disque** (téléchargement → ``CorpusSpec``) vit en couche
5
+ ``app`` (``app/corpus_import.py``) : un adapter parle protocole, pas filesystem.
6
+
7
+ Périmètre : **images seules**. Un manifeste IIIF porte des pages numérisées, pas
8
+ de transcription ; le corpus importé est donc sans vérité-terrain (OCR exécutable,
9
+ métriques non applicables tant qu'une GT n'est pas appariée). C'est un choix
10
+ assumé de la tranche, pas un manque.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_json
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class IIIFImage:
23
+ """Une page du manifeste : son URL d'image + un label lisible."""
24
+
25
+ image_url: str
26
+ label: str
27
+
28
+
29
+ def _extract_label(raw: object) -> str:
30
+ """Aplati les formats de label IIIF (str, liste, map de langue v3)."""
31
+ if isinstance(raw, str):
32
+ return raw
33
+ if isinstance(raw, list) and raw:
34
+ return _extract_label(raw[0])
35
+ if isinstance(raw, dict):
36
+ for lang in ("fr", "en", "none", "@value"):
37
+ val = raw.get(lang)
38
+ if val:
39
+ return str(val[0]) if isinstance(val, list) and val else str(val)
40
+ for val in raw.values():
41
+ return _extract_label(val)
42
+ return str(raw) if raw else ""
43
+
44
+
45
+ def _service_image_url(service: object) -> str:
46
+ """Construit ``{service}/full/max/0/default.jpg`` depuis un service Image API."""
47
+ if isinstance(service, list):
48
+ service = service[0] if service else {}
49
+ if not isinstance(service, dict):
50
+ return ""
51
+ svc_id = service.get("@id") or service.get("id") or ""
52
+ if not isinstance(svc_id, str) or not svc_id:
53
+ return ""
54
+ return f"{svc_id.rstrip('/')}/full/max/0/default.jpg"
55
+
56
+
57
+ def _image_url_v2(canvas: dict[str, Any]) -> str:
58
+ images = canvas.get("images") or []
59
+ if not images or not isinstance(images[0], dict):
60
+ return ""
61
+ resource = images[0].get("resource", {})
62
+ if not isinstance(resource, dict):
63
+ return ""
64
+ direct = resource.get("@id", "")
65
+ if isinstance(direct, str) and direct and not direct.endswith("/info.json"):
66
+ return direct
67
+ return _service_image_url(resource.get("service", {}))
68
+
69
+
70
+ def _image_url_v3(canvas: dict[str, Any]) -> str:
71
+ for annotation_page in canvas.get("items") or []:
72
+ for annotation in annotation_page.get("items") or []:
73
+ body = annotation.get("body", {})
74
+ if isinstance(body, list):
75
+ body = body[0] if body else {}
76
+ if not isinstance(body, dict):
77
+ continue
78
+ url = body.get("id") or body.get("@id") or ""
79
+ if isinstance(url, str) and url and body.get("type") == "Image":
80
+ return url
81
+ service_url = _service_image_url(body.get("service", []))
82
+ if service_url:
83
+ return service_url
84
+ if isinstance(url, str) and url:
85
+ return url
86
+ return ""
87
+
88
+
89
+ def _detect_version(manifest: dict[str, Any]) -> int:
90
+ context = manifest.get("@context", "")
91
+ contexts = context if isinstance(context, list) else [context]
92
+ if any(isinstance(c, str) and "presentation/3" in c for c in contexts):
93
+ return 3
94
+ if manifest.get("type") == "Manifest" and "items" in manifest:
95
+ return 3
96
+ return 2
97
+
98
+
99
+ def parse_manifest(manifest: dict[str, Any]) -> tuple[IIIFImage, ...]:
100
+ """Parse un manifeste IIIF (v2/v3 auto-détecté) → pages avec image. Pur."""
101
+ if _detect_version(manifest) == 3:
102
+ canvases = manifest.get("items") or []
103
+ extract = _image_url_v3
104
+ else:
105
+ sequences = manifest.get("sequences") or []
106
+ canvases = sequences[0].get("canvases", []) if sequences else []
107
+ extract = _image_url_v2
108
+ images: list[IIIFImage] = []
109
+ for i, canvas in enumerate(canvases, start=1):
110
+ if not isinstance(canvas, dict):
111
+ continue
112
+ url = extract(canvas)
113
+ if not url:
114
+ continue
115
+ label = _extract_label(canvas.get("label", f"canvas_{i}")) or f"canvas_{i}"
116
+ images.append(IIIFImage(image_url=url, label=label))
117
+ return tuple(images)
118
+
119
+
120
+ class IIIFImporter:
121
+ """Localise les pages d'un manifeste IIIF (fetch + parse, **sans disque**)."""
122
+
123
+ name = "iiif"
124
+
125
+ def __init__(self, *, timeout: float = DEFAULT_TIMEOUT) -> None:
126
+ self._timeout = timeout
127
+
128
+ def fetch_images(self, manifest_url: str) -> tuple[IIIFImage, ...]:
129
+ manifest = fetch_json(manifest_url, timeout=self._timeout)
130
+ if not isinstance(manifest, dict):
131
+ raise HttpFetchError(
132
+ f"manifeste IIIF {manifest_url!r} : objet JSON attendu."
133
+ )
134
+ return parse_manifest(manifest)
135
+
136
+
137
+ __all__ = ["IIIFImage", "IIIFImporter", "parse_manifest"]
cinoc/adapters/images.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vignettes du rapport : image → dérivé JPEG redimensionné (couche 5, adapter).
2
+
3
+ Le redimensionnement utilise **Pillow** (interdit en ``reports`` — couche 7) ; il
4
+ vit donc ici. Deux saveurs partagent le **même cœur** ``_render_jpeg`` :
5
+
6
+ - ``thumbnail_data_uri`` → **data-URI** embarqué dans le HTML (saveur fichier
7
+ unique) ; les octets ne touchent jamais le ``RunResult``.
8
+ - ``thumbnail_to_file`` → écrit le dérivé dans un dossier d'assets et renvoie un
9
+ **href relatif** (saveur dossier : HTML léger à liens relatifs).
10
+
11
+ **Dégradé gracieux** : si Pillow n'est pas installé (extra ``[images]``), si le
12
+ fichier manque ou est illisible → ``None`` (le rapport retombe sur l'aperçu
13
+ synthétique). Aucune exception large.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import io
20
+ import logging
21
+ from pathlib import Path
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def thumbnail_bytes(source: str | Path, *, max_px: int = 280) -> bytes | None:
27
+ """Octets JPEG d'une image locale redimensionnée ; ``None`` si indisponible.
28
+
29
+ Cœur partagé des **trois** saveurs — data-URI (fichier unique), fichier
30
+ (dossier) et **servie** (le web renvoie ces octets tels quels) : redimensionne
31
+ pour que le
32
+ plus grand côté ≤ ``max_px`` (jamais agrandi), convertit en RGB, encode JPEG.
33
+ ``None`` (avec ``logger.warning``) si Pillow absent, fichier introuvable, ou
34
+ image illisible — le rendu reste fonctionnel (aperçu synthétique)."""
35
+ path = Path(source)
36
+ if not path.is_file():
37
+ return None
38
+ try:
39
+ from PIL import Image, UnidentifiedImageError
40
+ except ImportError as exc:
41
+ logger.warning(
42
+ "[images] Pillow indisponible (%s) — vignettes omises, aperçu "
43
+ "synthétique. Installer l'extra ``[images]``.",
44
+ exc,
45
+ )
46
+ return None
47
+ try:
48
+ with Image.open(path) as opened:
49
+ rgb = opened.convert("RGB")
50
+ rgb.thumbnail((max_px, max_px))
51
+ buffer = io.BytesIO()
52
+ rgb.save(buffer, format="JPEG", quality=82, optimize=True)
53
+ except (UnidentifiedImageError, OSError) as exc:
54
+ logger.warning("[images] image illisible %s : %s — vignette omise.", path, exc)
55
+ return None
56
+ return buffer.getvalue()
57
+
58
+
59
+ def thumbnail_data_uri(source: str | Path, *, max_px: int = 280) -> str | None:
60
+ """Vignette JPEG **data-URI** d'une image locale ; ``None`` si indisponible.
61
+
62
+ Saveur fichier unique : les octets sont inlinés dans le HTML. ``None`` si le
63
+ dérivé n'a pu être produit (Pillow absent, fichier manquant/illisible)."""
64
+ raw = thumbnail_bytes(source, max_px=max_px)
65
+ if raw is None:
66
+ return None
67
+ payload = base64.b64encode(raw).decode("ascii")
68
+ return f"data:image/jpeg;base64,{payload}"
69
+
70
+
71
+ def thumbnail_to_file(
72
+ source: str | Path, dest_dir: str | Path, stem: str, *, max_px: int = 280
73
+ ) -> str | None:
74
+ """Écrit la vignette JPEG dans ``dest_dir/<stem>.jpg`` ; renvoie un href relatif.
75
+
76
+ Saveur dossier : le dérivé est **un fichier sur disque** (pas inliné), le HTML
77
+ n'en porte qu'une **référence relative**. L'href renvoyé est relatif au
78
+ **parent** de ``dest_dir`` (``f"{dest_dir.name}/{stem}.jpg"``, séparateur ``/``
79
+ portable) : l'appelant garantit que le HTML est un voisin de ``dest_dir`` (cf.
80
+ ``app.report_images.write_report_bundle``). ``None`` si le dérivé n'a pu être
81
+ produit (dégradé gracieux, aucun fichier écrit, aucun dossier créé)."""
82
+ raw = thumbnail_bytes(source, max_px=max_px)
83
+ if raw is None:
84
+ return None
85
+ folder = Path(dest_dir)
86
+ folder.mkdir(parents=True, exist_ok=True)
87
+ (folder / f"{stem}.jpg").write_bytes(raw)
88
+ return f"{folder.name}/{stem}.jpg"
89
+
90
+
91
+ def iiif_derivative(
92
+ source: str | Path, dest_path: str | Path, *, width: int
93
+ ) -> tuple[int, int] | None:
94
+ """Écrit un dérivé JPEG de **largeur exacte** ``width`` (jamais agrandi) à
95
+ ``dest_path`` ; renvoie ``(largeur, hauteur)`` réels, ou ``None`` (dégradé).
96
+
97
+ Variante **largeur-fixe** de ``_render_jpeg`` (qui borne le plus grand côté) :
98
+ une taille IIIF Image API ``full/{w},/`` exige une largeur exacte, pas un
99
+ cadrage. Convertit en RGB, ré-échantillonne (Lanczos), crée les dossiers
100
+ parents. ``None`` (avec ``logger.warning``) si Pillow absent, fichier
101
+ introuvable ou image illisible — l'outil appelant décide d'échouer ou non."""
102
+ path = Path(source)
103
+ if not path.is_file():
104
+ logger.warning("[images] image introuvable %s — dérivé IIIF omis.", path)
105
+ return None
106
+ try:
107
+ from PIL import Image, UnidentifiedImageError
108
+ except ImportError as exc:
109
+ logger.warning(
110
+ "[images] Pillow indisponible (%s) — dérivé IIIF omis. Extra ``[images]``.",
111
+ exc,
112
+ )
113
+ return None
114
+ try:
115
+ with Image.open(path) as opened:
116
+ rgb = opened.convert("RGB")
117
+ orig_w, orig_h = rgb.size
118
+ target_w = min(width, orig_w) # jamais agrandi
119
+ target_h = max(1, round(orig_h * target_w / orig_w))
120
+ resized = rgb.resize((target_w, target_h), Image.Resampling.LANCZOS)
121
+ dest = Path(dest_path)
122
+ dest.parent.mkdir(parents=True, exist_ok=True)
123
+ resized.save(dest, format="JPEG", quality=82, optimize=True)
124
+ except (UnidentifiedImageError, OSError) as exc:
125
+ logger.warning(
126
+ "[images] image illisible %s : %s — dérivé IIIF omis.", path, exc
127
+ )
128
+ return None
129
+ return (target_w, target_h)
130
+
131
+
132
+ __all__ = [
133
+ "iiif_derivative",
134
+ "thumbnail_bytes",
135
+ "thumbnail_data_uri",
136
+ "thumbnail_to_file",
137
+ ]
cinoc/adapters/layout/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Adapters de mise en page (couche 5) — socle ``precomputed``."""
2
+
3
+ from __future__ import annotations
cinoc/adapters/layout/_base.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contrat **partagé** des segmenteurs de mise en page (couche 5).
2
+
3
+ Tout segmenteur (PP-DocLayout maison, segmenteur distant HF, futurs) produit la
4
+ même forme : un détecteur rend des **régions brutes** (``LayoutDetection``), qu'on
5
+ convertit en ``CanonicalLayout`` neutre (``to_canonical_layout``) puis qu'on
6
+ sérialise en artefact ``LAYOUT`` (``layout_step_output``, la *queue* d'``execute``).
7
+
8
+ Ces pièces sont **agnostiques du moteur** : elles vivaient dans ``pp_doclayout``
9
+ (le segmenteur maison), ce qui forçait le segmenteur distant à importer un module
10
+ PaddleX-spécifique pour les réutiliser. Extraites ici, les deux adapters en
11
+ dépendent sans se coupler l'un à l'autre.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+
19
+ from cinoc.adapters._workspace import workspace_artifact_path
20
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
21
+ from cinoc.domain.errors import AdapterStepError
22
+ from cinoc.domain.layout import BBox, CanonicalLayout, Geometry, LayoutPage, Region
23
+ from cinoc.pipeline.types import RunContext, StepOutput
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DetectedRegion:
28
+ """Une région détectée : étiquette, boîte (pixels) et score de confiance."""
29
+
30
+ label: str
31
+ x: int
32
+ y: int
33
+ width: int
34
+ height: int
35
+ score: float
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class LayoutDetection:
40
+ """Sortie brute d'un détecteur : dimensions de page + régions détectées."""
41
+
42
+ page_width: int
43
+ page_height: int
44
+ regions: tuple[DetectedRegion, ...]
45
+
46
+
47
+ #: Détecteur **injectable** : chemin image → détection. Le défaut (PaddleX, appel
48
+ #: distant…) est résolu paresseusement à l'exécution ; les tests injectent un faux.
49
+ DetectorFn = Callable[[str], LayoutDetection]
50
+
51
+
52
+ def to_canonical_layout(
53
+ detection: LayoutDetection, *, min_score: float
54
+ ) -> CanonicalLayout:
55
+ """Détections → ``CanonicalLayout`` (1 page, régions **sans lignes**).
56
+
57
+ Filtre au seuil de score, **trie** par position (haut→bas puis gauche→droite)
58
+ pour un ordre de lecture **déterministe** indépendant de l'ordre de détection,
59
+ et numérote les régions ``r1..rN``. Le ``label`` du modèle devient le
60
+ ``region_type`` neutre (``None`` si vide).
61
+ """
62
+ kept = [r for r in detection.regions if r.score >= min_score]
63
+ ordered = sorted(kept, key=lambda r: (r.y, r.x))
64
+ regions = tuple(
65
+ Region(
66
+ id=f"r{index + 1}",
67
+ region_type=r.label or None,
68
+ geometry=Geometry(
69
+ bbox=BBox(x=r.x, y=r.y, width=r.width, height=r.height)
70
+ ),
71
+ )
72
+ for index, r in enumerate(ordered)
73
+ )
74
+ page = LayoutPage(
75
+ width=detection.page_width or None,
76
+ height=detection.page_height or None,
77
+ regions=regions,
78
+ reading_order=tuple(region.id for region in regions),
79
+ )
80
+ return CanonicalLayout(pages=(page,))
81
+
82
+
83
+ def layout_step_output(
84
+ layout: CanonicalLayout, context: RunContext, name: str
85
+ ) -> StepOutput:
86
+ """Sérialise un ``CanonicalLayout`` → artefact ``LAYOUT`` (tail d'``execute``
87
+ partagé par tous les segmenteurs : PP-DocLayout, segmenteur distant…)."""
88
+ if context.workspace_uri is None:
89
+ raise AdapterStepError(f"{name} : workspace requis (RunContext.workspace_uri).")
90
+ payload = layout.model_dump_json().encode("utf-8")
91
+ output_path = workspace_artifact_path(
92
+ context.workspace_uri, context.document_id, name, "layout.json"
93
+ )
94
+ output_path.write_bytes(payload)
95
+ return StepOutput(
96
+ artifacts={
97
+ ArtifactType.LAYOUT: Artifact(
98
+ id=f"{context.document_id}:{name}:layout",
99
+ document_id=context.document_id,
100
+ type=ArtifactType.LAYOUT,
101
+ uri=str(output_path),
102
+ content_hash=compute_content_hash(payload),
103
+ )
104
+ }
105
+ )
106
+
107
+
108
+ __all__ = [
109
+ "DetectedRegion",
110
+ "DetectorFn",
111
+ "LayoutDetection",
112
+ "layout_step_output",
113
+ "to_canonical_layout",
114
+ ]
cinoc/adapters/layout/_saknussemm_bridge.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pont ``CanonicalLayout`` → ``DocumentManifest`` de ``saknussemm``.
2
+
3
+ Isolé de l'adapter pour une raison de contrat : c'est **la** traduction entre
4
+ deux modèles de mise en page, et elle a un coût mesurable. Vérifiée sur les deux
5
+ corpus du dépôt (1088 lignes, ``37-GT-BNL`` + ``BnF-bpt6k3265015q``) : rôle de
6
+ césure, texte de ligne, confiance et ``SUBS_CONTENT`` identiques à ce que le
7
+ parser de ``saknussemm`` obtient en lisant l'ALTO lui-même.
8
+
9
+ Cette parité **dépend** du fait que ``alto_to_layout`` transporte les ``SP``,
10
+ les ``HYP`` et les ``SUBS``. Avant qu'il le fasse, la même conversion perdait
11
+ 36 % des rôles de césure et faussait 21 % des textes sur le corpus BnF.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from cinoc.domain.errors import AdapterStepError
19
+
20
+ if TYPE_CHECKING: # pragma: no cover
21
+ from cinoc.domain.layout import CanonicalLayout, Region
22
+
23
+
24
+ def _flatten_regions(regions: tuple[Region, ...]) -> list[Region]:
25
+ """Régions imbriquées aplaties : un bloc ALTO composé porte ses lignes dans
26
+ ses enfants, et ``saknussemm`` raisonne sur des blocs plats."""
27
+ out: list[Region] = []
28
+ for region in regions:
29
+ if region.lines:
30
+ out.append(region)
31
+ out.extend(_flatten_regions(region.regions))
32
+ return out
33
+
34
+
35
+ def layout_to_manifest(layout: CanonicalLayout, *, document_id: str) -> Any:
36
+ """Traduit une mise en page neutre en manifeste ``saknussemm``.
37
+
38
+ Lève si une ligne n'a pas d'identifiant : ``saknussemm`` fait de
39
+ ``(page_id, line_id)`` **l'identité** d'une ligne, pas son rang. Une ligne
40
+ anonyme ne peut donc pas traverser la bibliothèque, et le dire ici nomme la
41
+ vraie cause.
42
+ """
43
+ from saknussemm.core.pairing import ( # type: ignore[import-not-found] # noqa: PLC0415
44
+ HYPHEN_CHARS,
45
+ link_hyphen_pairs,
46
+ trailing_hyphen_char,
47
+ )
48
+ from saknussemm.core.schemas import ( # type: ignore[import-not-found] # noqa: PLC0415
49
+ BlockManifest,
50
+ Coords,
51
+ DocumentManifest,
52
+ HyphenRole,
53
+ LineManifest,
54
+ PageManifest,
55
+ )
56
+
57
+ pages = []
58
+ for page_index, lpage in enumerate(layout.pages):
59
+ page_id = f"P_{page_index + 1:04d}"
60
+ blocks, lines = [], []
61
+ order = 0
62
+ for block_index, region in enumerate(_flatten_regions(lpage.regions)):
63
+ block_id = region.id or f"B_{block_index + 1:04d}"
64
+ line_ids = []
65
+ for line_index, line in enumerate(region.lines):
66
+ if not line.id:
67
+ raise AdapterStepError(
68
+ "layout_to_manifest : une ligne du bloc "
69
+ f"{block_id!r} n'a pas d'identifiant. saknussemm fait "
70
+ "de (page, ligne) une identité, pas un rang."
71
+ )
72
+ confidences = [
73
+ w.confidence for w in line.words if w.confidence is not None
74
+ ]
75
+ first = line.words[0] if line.words else None
76
+ last = line.words[-1] if line.words else None
77
+ lm = LineManifest(
78
+ line_id=line.id,
79
+ page_id=page_id,
80
+ block_id=block_id,
81
+ line_order_global=order,
82
+ line_order_in_block=line_index,
83
+ coords=_coords(Coords, line),
84
+ ocr_text=line.text,
85
+ # ``None`` et non ``len(words)`` : en ALTO la géométrie par
86
+ # jeton se redistribue à n'importe quel compte de mots, donc
87
+ # le compte n'est pas une contrainte de projection.
88
+ word_count=None,
89
+ ocr_confidence=(
90
+ sum(confidences) / len(confidences) if confidences else None
91
+ ),
92
+ )
93
+ _set_hyphenation(
94
+ lm,
95
+ HyphenRole,
96
+ trailing_hyphen_char,
97
+ HYPHEN_CHARS,
98
+ first_subs=_subs(first),
99
+ last_subs=_subs(last),
100
+ )
101
+ lines.append(lm)
102
+ line_ids.append(line.id)
103
+ order += 1
104
+ blocks.append(
105
+ BlockManifest(
106
+ block_id=block_id,
107
+ page_id=page_id,
108
+ block_order=block_index,
109
+ coords=_coords(Coords, region),
110
+ line_ids=line_ids,
111
+ )
112
+ )
113
+ link_hyphen_pairs(lines)
114
+ pages.append(
115
+ PageManifest(
116
+ page_id=page_id,
117
+ source_file=f"{document_id}#{page_index}",
118
+ page_index=page_index,
119
+ page_width=lpage.width or 0,
120
+ page_height=lpage.height or 0,
121
+ blocks=blocks,
122
+ lines=lines,
123
+ )
124
+ )
125
+ return DocumentManifest(
126
+ document_id=document_id,
127
+ source_files=[p.source_file for p in pages],
128
+ pages=pages,
129
+ )
130
+
131
+
132
+ def _subs(word: Any) -> tuple[str | None, str | None]:
133
+ """``(SUBS_TYPE, SUBS_CONTENT)`` d'un mot, ou ``(None, None)`` sans mot."""
134
+ if word is None:
135
+ return None, None
136
+ return word.subs_type, word.subs_content
137
+
138
+
139
+ def _coords(coords_cls: Any, element: Any) -> Any:
140
+ box = element.geometry.bbox if element.geometry else None
141
+ return coords_cls(
142
+ hpos=box.x if box else 0,
143
+ vpos=box.y if box else 0,
144
+ width=box.width if box else 0,
145
+ height=box.height if box else 0,
146
+ )
147
+
148
+
149
+ def _set_hyphenation(
150
+ lm: Any,
151
+ hyphen_role: Any,
152
+ trailing: Any,
153
+ hyphen_chars: Any,
154
+ *,
155
+ first_subs: tuple[str | None, str | None],
156
+ last_subs: tuple[str | None, str | None],
157
+ ) -> None:
158
+ """Deux sources, comme le parser ALTO de ``saknussemm`` : le ``SUBS_TYPE``
159
+ (explicite) et une marque de coupure en fin de ligne (heuristique)."""
160
+ first_type, first_content = first_subs
161
+ last_type, last_content = last_subs
162
+ is_part2 = first_type == "HypPart2"
163
+ is_part1 = last_type == "HypPart1" or (
164
+ trailing(lm.ocr_text, hyphen_chars) is not None
165
+ )
166
+ if is_part1 and is_part2:
167
+ lm.hyphen_role = hyphen_role.BOTH
168
+ lm.hyphen_source_explicit = True
169
+ lm.hyphen_subs_content = first_content
170
+ lm.hyphen_forward_explicit = last_type == "HypPart1"
171
+ lm.hyphen_forward_subs_content = last_content
172
+ elif is_part2:
173
+ lm.hyphen_role = hyphen_role.PART2
174
+ lm.hyphen_source_explicit = True
175
+ lm.hyphen_subs_content = first_content
176
+ elif is_part1:
177
+ lm.hyphen_role = hyphen_role.PART1
178
+ lm.hyphen_source_explicit = last_type == "HypPart1"
179
+ lm.hyphen_subs_content = last_content
180
+
181
+
182
+ def manifest_page_ids(manifest: Any) -> list[str]:
183
+ """Les ``page_id`` fabriqués ci-dessus, dans l'ordre des pages du layout."""
184
+ return [page.page_id for page in manifest.pages]
185
+
186
+
187
+ __all__ = ["layout_to_manifest", "manifest_page_ids"]
cinoc/adapters/layout/alto_source.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``AltoLayoutSource`` — ``IMAGE → LAYOUT`` depuis un ALTO déjà là (couche 5).
2
+
3
+ Le banc sait produire une mise en page (segmenteurs) et sait en consommer une
4
+ (``to_text``, ``assembler``, métriques de structure). Il ne savait pas **en
5
+ lire une qui existe déjà** : un corpus patrimonial arrive presque toujours
6
+ accompagné de son ALTO, et rien ne permettait de le faire entrer autrement
7
+ qu'aplati en texte.
8
+
9
+ Convention de ``PrecomputedLayoutSource`` reprise telle quelle — l'ALTO est
10
+ cherché **à côté de l'image**, ``<stem>.xml`` près de ``<stem>.png``. C'est la
11
+ façon dont les corpus de ce dépôt sont rangés, et ça évite d'ajouter un type
12
+ d'entrée initial au planificateur.
13
+
14
+ Ce que la lecture conserve, et qui n'a de valeur que conservé : l'identifiant
15
+ de ligne (une identité, pas un rang), la géométrie, la confiance du moteur
16
+ d'origine, et la césure (``SUBS_TYPE``/``SUBS_CONTENT`` + la marque portée par
17
+ ``<HYP>``). Un post-correcteur qui recolle un mot coupé a besoin des quatre.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from pathlib import Path
24
+
25
+ from cinoc.adapters._workspace import workspace_artifact_path
26
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
27
+ from cinoc.domain.errors import AdapterStepError, CinocError
28
+ from cinoc.domain.layout import CanonicalLayout, Line, Region
29
+ from cinoc.formats.alto.layout_map import alto_to_layout
30
+ from cinoc.formats.alto.parser import parse_alto
31
+ from cinoc.pipeline.protocols import ParamValue
32
+ from cinoc.pipeline.run_control import RunControl
33
+ from cinoc.pipeline.types import RunContext, StepOutput
34
+
35
+ _VERSION = "1.0"
36
+
37
+ #: Extensions tentées à côté de l'image, dans l'ordre.
38
+ _SUFFIXES = (".xml", ".alto.xml")
39
+
40
+
41
+ class AltoLayoutSource:
42
+ """Lit l'ALTO voisin de l'image et le projette en ``CanonicalLayout``.
43
+
44
+ ``ocr_sidecar`` remplace le **texte** des lignes par une lecture d'OCR
45
+ réelle, en gardant de l'ALTO tout le reste — identité, géométrie, césure.
46
+ Sans lui, un corpus à vérité terrain fait entrer sa propre référence dans
47
+ le banc : le correcteur n'a rien à corriger et le CER vaut zéro **par
48
+ construction**. Un zéro tautologique ressemble à un excellent résultat, ce
49
+ qui en fait le plus trompeur de tous.
50
+
51
+ Format attendu (celui de ``scripts/ocr_corpus.py``) : ``{"ocr": {fichier:
52
+ {line_id: texte}}}``. Une ligne absente du sidecar **garde son texte
53
+ d'ALTO** et est comptée ; une ligne présente mais **vide** est une lecture
54
+ d'OCR à part entière — le moteur n'a rien reconnu — et remplace donc le
55
+ texte. Confondre les deux crédite le moteur d'une ligne qu'il n'a pas lue.
56
+ """
57
+
58
+ def __init__(self, *, ocr_sidecar: str = "") -> None:
59
+ self._sidecar = ocr_sidecar
60
+
61
+ @property
62
+ def name(self) -> str:
63
+ return "alto_source"
64
+
65
+ @property
66
+ def version(self) -> str:
67
+ return _VERSION
68
+
69
+ @property
70
+ def input_types(self) -> frozenset[ArtifactType]:
71
+ return frozenset({ArtifactType.IMAGE})
72
+
73
+ @property
74
+ def output_types(self) -> frozenset[ArtifactType]:
75
+ return frozenset({ArtifactType.LAYOUT})
76
+
77
+ def _locate(self, image_path: Path) -> Path:
78
+ for suffix in _SUFFIXES:
79
+ candidate = image_path.with_name(image_path.stem + suffix)
80
+ if candidate.is_file():
81
+ return candidate
82
+ attendus = " ou ".join(repr(image_path.stem + s) for s in _SUFFIXES)
83
+ raise AdapterStepError(
84
+ f"{self.name} : aucun ALTO près de {image_path.name!r} "
85
+ f"({attendus} attendu dans {image_path.parent})."
86
+ )
87
+
88
+ def _read_sidecar(self, alto_name: str) -> dict[str, str]:
89
+ chemin = Path(self._sidecar)
90
+ try:
91
+ data = json.loads(chemin.read_text(encoding="utf-8"))
92
+ except (OSError, ValueError) as exc:
93
+ raise AdapterStepError(
94
+ f"{self.name} : sidecar OCR illisible ({chemin}) — {exc}"
95
+ ) from exc
96
+ par_fichier = data.get("ocr") if isinstance(data, dict) else None
97
+ if not isinstance(par_fichier, dict):
98
+ raise AdapterStepError(
99
+ f"{self.name} : {chemin.name!r} n'a pas de section 'ocr'."
100
+ )
101
+ lectures = par_fichier.get(alto_name)
102
+ if not isinstance(lectures, dict):
103
+ raise AdapterStepError(
104
+ f"{self.name} : le sidecar ne connaît pas {alto_name!r}."
105
+ )
106
+ return {str(k): str(v) for k, v in lectures.items()}
107
+
108
+ def execute(
109
+ self,
110
+ inputs: dict[ArtifactType, Artifact],
111
+ params: dict[str, ParamValue], # noqa: ARG002 — contrat Module
112
+ context: RunContext,
113
+ control: RunControl,
114
+ ) -> StepOutput:
115
+ control.raise_if_cancelled()
116
+ image = inputs.get(ArtifactType.IMAGE)
117
+ if image is None or image.uri is None:
118
+ raise AdapterStepError(
119
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
120
+ )
121
+ alto_path = self._locate(Path(image.uri))
122
+ try:
123
+ layout = alto_to_layout(parse_alto(alto_path.read_bytes()))
124
+ except CinocError as exc:
125
+ raise AdapterStepError(
126
+ f"{self.name} : {alto_path.name!r} illisible — {exc}"
127
+ ) from exc
128
+ if self._sidecar:
129
+ layout, remplacees, total = _apply_ocr(
130
+ layout, self._read_sidecar(alto_path.name)
131
+ )
132
+ if remplacees == 0 and total:
133
+ # Un sidecar qui ne touche AUCUNE ligne est presque toujours un
134
+ # mauvais appariement de clés, pas un OCR parfait. Le dire ici
135
+ # évite de publier un CER nul en croyant mesurer une correction.
136
+ raise AdapterStepError(
137
+ f"{self.name} : le sidecar ne couvre aucune des {total} "
138
+ f"lignes de {alto_path.name!r} — clés de fichier ou de "
139
+ "ligne incompatibles."
140
+ )
141
+
142
+ # Une ligne sans ``id`` n'a pas d'identité stable, et un post-correcteur
143
+ # qui reçoit un tel layout ne peut pas rendre ses décisions ligne à
144
+ # ligne. On le dit ici plutôt que de laisser l'étape suivante échouer
145
+ # sur une cause qui n'est pas la sienne.
146
+ sans_id = sum(
147
+ 1
148
+ for page in layout.pages
149
+ for region in page.regions
150
+ for line in region.lines
151
+ if not line.id
152
+ )
153
+ if sans_id:
154
+ raise AdapterStepError(
155
+ f"{self.name} : {alto_path.name!r} porte {sans_id} ligne(s) "
156
+ "sans attribut ID. Sans identifiant, une ligne n'a pas "
157
+ "d'identité stable d'un artefact à l'autre."
158
+ )
159
+
160
+ payload = layout.model_dump_json().encode("utf-8")
161
+ # Sans espace de travail, on écrit près de la source — même repli que
162
+ # ``alto_assembler``.
163
+ out = (
164
+ workspace_artifact_path(
165
+ context.workspace_uri, context.document_id, self.name, "layout.json"
166
+ )
167
+ if context.workspace_uri
168
+ else alto_path.with_name(f"{alto_path.stem}.{self.name}.layout.json")
169
+ )
170
+ out.parent.mkdir(parents=True, exist_ok=True)
171
+ out.write_bytes(payload)
172
+ return StepOutput(
173
+ artifacts={
174
+ ArtifactType.LAYOUT: Artifact(
175
+ id=f"{context.document_id}:{self.name}:layout",
176
+ document_id=context.document_id,
177
+ type=ArtifactType.LAYOUT,
178
+ uri=str(out),
179
+ content_hash=compute_content_hash(payload),
180
+ )
181
+ }
182
+ )
183
+
184
+
185
+ def _apply_ocr(
186
+ layout: CanonicalLayout, lectures: dict[str, str]
187
+ ) -> tuple[CanonicalLayout, int, int]:
188
+ """Remplace le texte des lignes connues du sidecar. Renvoie ``(layout,
189
+ remplacées, total)`` — les compteurs servent à refuser un appariement vide.
190
+
191
+ Les **mots sont abandonnés** sur une ligne remplacée : leur géométrie
192
+ décrivait les caractères de la vérité terrain, pas ceux que l'OCR a lus.
193
+ """
194
+ remplacees = 0
195
+ total = 0
196
+
197
+ def region(reg: Region) -> Region:
198
+ nonlocal remplacees, total
199
+ lignes = []
200
+ for line in reg.lines:
201
+ total += 1
202
+ if line.id is not None and line.id in lectures:
203
+ remplacees += 1
204
+ lignes.append(
205
+ Line(
206
+ id=line.id,
207
+ text=lectures[line.id],
208
+ geometry=line.geometry,
209
+ baseline=line.baseline,
210
+ words=(),
211
+ confidence=line.confidence,
212
+ )
213
+ )
214
+ else:
215
+ lignes.append(line)
216
+ return reg.model_copy(
217
+ update={
218
+ "lines": tuple(lignes),
219
+ "regions": tuple(region(r) for r in reg.regions),
220
+ }
221
+ )
222
+
223
+ pages = tuple(
224
+ page.model_copy(update={"regions": tuple(region(r) for r in page.regions)})
225
+ for page in layout.pages
226
+ )
227
+ return layout.model_copy(update={"pages": pages}), remplacees, total
228
+
229
+
230
+ __all__ = ["AltoLayoutSource"]
cinoc/adapters/layout/assembler.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``AltoAssembler`` — assemblage ``LAYOUT → ALTO_XML`` (couche 5).
2
+
3
+ Troisième étage du pipeline de segmentation (``CLAUDE.md`` §3 : *assemblage
4
+ LAYOUT → ALTO_XML*). C'est un **constructeur d'ALTO** au sens du même `Module`
5
+ Protocol que les autres briques : il prend un ``CanonicalLayout`` **rempli**
6
+ (sortie du fan-out, sérialisé JSON) et émet de l'ALTO 4 déterministe
7
+ (``layout_to_alto`` + ``write_alto`` octet-stable).
8
+
9
+ L'assembleur vit en couche 5 (et non `pipeline`) parce qu'il dépend de `formats`
10
+ (`write_alto`) — un import légal pour un adapter, interdit à `pipeline`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
18
+ from cinoc.domain.errors import AdapterStepError
19
+ from cinoc.domain.layout import CanonicalLayout
20
+ from cinoc.formats.alto import write_alto
21
+ from cinoc.formats.alto.layout_map import layout_to_alto
22
+ from cinoc.pipeline.protocols import ParamValue
23
+ from cinoc.pipeline.run_control import RunControl
24
+ from cinoc.pipeline.types import RunContext, StepOutput
25
+
26
+ _VERSION = "1.0"
27
+
28
+
29
+ class AltoAssembler:
30
+ """Assemble un ``LAYOUT`` rempli en ``ALTO_XML`` (ALTO 4, déterministe)."""
31
+
32
+ @property
33
+ def name(self) -> str:
34
+ return "alto_assembler"
35
+
36
+ @property
37
+ def version(self) -> str:
38
+ return _VERSION
39
+
40
+ @property
41
+ def input_types(self) -> frozenset[ArtifactType]:
42
+ return frozenset({ArtifactType.LAYOUT})
43
+
44
+ @property
45
+ def output_types(self) -> frozenset[ArtifactType]:
46
+ return frozenset({ArtifactType.ALTO_XML})
47
+
48
+ def execute(
49
+ self,
50
+ inputs: dict[ArtifactType, Artifact],
51
+ params: dict[str, ParamValue],
52
+ context: RunContext,
53
+ control: RunControl,
54
+ ) -> StepOutput:
55
+ control.raise_if_cancelled()
56
+ layout_art = inputs.get(ArtifactType.LAYOUT)
57
+ if layout_art is None or layout_art.uri is None:
58
+ raise AdapterStepError(
59
+ f"{self.name} : artefact LAYOUT manquant ou sans URI."
60
+ )
61
+ layout_path = Path(layout_art.uri)
62
+ try:
63
+ layout = CanonicalLayout.model_validate_json(layout_path.read_bytes())
64
+ except (OSError, ValueError) as exc:
65
+ raise AdapterStepError(
66
+ f"{self.name} : LAYOUT illisible ({layout_path.name!r}) : {exc}"
67
+ ) from exc
68
+ payload = write_alto(layout_to_alto(layout))
69
+ out_dir = (
70
+ Path(context.workspace_uri)
71
+ if context.workspace_uri
72
+ else layout_path.parent
73
+ )
74
+ out_path = out_dir / f"{context.document_id.replace('/', '_')}.alto.xml"
75
+ out_path.write_bytes(payload)
76
+ return StepOutput(
77
+ artifacts={
78
+ ArtifactType.ALTO_XML: Artifact(
79
+ id=f"{context.document_id}:{self.name}:alto_xml",
80
+ document_id=context.document_id,
81
+ type=ArtifactType.ALTO_XML,
82
+ uri=str(out_path),
83
+ content_hash=compute_content_hash(payload),
84
+ )
85
+ }
86
+ )
87
+
88
+
89
+ __all__ = ["AltoAssembler"]
cinoc/adapters/layout/crop.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``crop_region`` — découpe l'image d'un bloc pour l'OCR par région (couche 5).
2
+
3
+ Brique du pipeline **hybride** « segmentation externe → OCR des blocs » : le
4
+ fan-out (couche 4) fournit une **boîte relative** ``(x0, y0, x1, y1) ∈ [0, 1]``
5
+ (la couche 4 a déjà neutralisé les unités, ALTO ``mm10`` vs pixels) ; ici on
6
+ ouvre l'image, on convertit en pixels et on découpe — le crop réel vit en
7
+ couche 5 car il dépend de ``PIL`` (interdit à ``pipeline``).
8
+
9
+ ``Pillow`` est un extra optionnel, importé **paresseusement** : importer ce
10
+ module n'exige pas la lib, seule l'exécution.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+ from urllib.parse import quote
17
+
18
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
19
+ from cinoc.domain.errors import AdapterStepError
20
+ from cinoc.pipeline.types import RunContext
21
+
22
+ _RelBox = tuple[float, float, float, float]
23
+
24
+
25
+ def crop_region(
26
+ page_image: Artifact, rel_bbox: _RelBox, region_id: str, context: RunContext
27
+ ) -> Artifact:
28
+ """Découpe ``rel_bbox`` (relatif) de l'image page → artefact ``IMAGE`` du bloc."""
29
+ if page_image.uri is None:
30
+ raise AdapterStepError("crop_region : image page sans URI.")
31
+ if context.workspace_uri is None:
32
+ raise AdapterStepError("crop_region : workspace requis pour écrire le crop.")
33
+ try:
34
+ from PIL import Image # type: ignore[import-not-found]
35
+ except ImportError as exc:
36
+ raise AdapterStepError(
37
+ "crop_region : Pillow non installé (pip install 'cinoc[tesseract]')."
38
+ ) from exc
39
+ try:
40
+ with Image.open(page_image.uri) as image:
41
+ width, height = image.size
42
+ box = (
43
+ max(0, min(width, round(rel_bbox[0] * width))),
44
+ max(0, min(height, round(rel_bbox[1] * height))),
45
+ max(0, min(width, round(rel_bbox[2] * width))),
46
+ max(0, min(height, round(rel_bbox[3] * height))),
47
+ )
48
+ if box[2] <= box[0] or box[3] <= box[1]:
49
+ raise AdapterStepError(
50
+ f"crop_region : boîte vide pour la région {region_id!r}."
51
+ )
52
+ crop = image.crop(box)
53
+ stem = quote(region_id, safe="")
54
+ out_path = Path(context.workspace_uri) / (
55
+ f"{quote(context.document_id, safe='')}.{stem}.crop.png"
56
+ )
57
+ crop.save(out_path)
58
+ except OSError as exc:
59
+ raise AdapterStepError(
60
+ f"crop_region : image illisible ({page_image.uri!r}) : {exc}"
61
+ ) from exc
62
+ return Artifact(
63
+ id=f"{context.document_id}:crop:{region_id}",
64
+ document_id=context.document_id,
65
+ type=ArtifactType.IMAGE,
66
+ uri=str(out_path),
67
+ content_hash=compute_content_hash(out_path.read_bytes()),
68
+ region_id=region_id,
69
+ )
70
+
71
+
72
+ __all__ = ["crop_region"]
cinoc/adapters/layout/pp_doclayout.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``PPDocLayoutSegmenter`` — segmenteur de mise en page réel (PP-DocLayout).
2
+
3
+ Détecte les **régions** d'une page (titre, paragraphe, figure, tableau…) et émet
4
+ un ``LAYOUT`` (``CanonicalLayout`` à régions **sans lignes** — la reconnaissance
5
+ les remplit ensuite, cf. fan-out couche 4). Implémente le ``Module`` Protocol
6
+ **directement**, comme les autres adapters.
7
+
8
+ Module **maison du socle** (≠ plugin tiers découvert) : enregistré par
9
+ ``register_default_modules`` → disponible **même en mode public** sur le Space
10
+ (contrairement aux plugins via entry-points, fail-closed). Son SDK (PaddleX,
11
+ Apache-2.0) est un **extra optionnel** (``cinoc[segment]``), importé
12
+ **paresseusement** : importer ce module n'exige ni la lib ni les poids — seule
13
+ l'exécution les requiert.
14
+
15
+ Le détecteur est **injectable** (``detector=``) : la conversion détections →
16
+ ``CanonicalLayout`` se teste sans SDK ni poids (CI), le vrai modèle reste un test
17
+ ``live``/``slow`` opt-in. Déterminisme (§12) : à modèle + poids + params figés et
18
+ seuil de score fixe, la sortie est stable ; l'ordre de lecture est **trié**
19
+ (haut→bas puis gauche→droite), indépendant de l'ordre de détection.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from cinoc.adapters.layout._base import (
25
+ DetectedRegion,
26
+ DetectorFn,
27
+ LayoutDetection,
28
+ layout_step_output,
29
+ to_canonical_layout,
30
+ )
31
+ from cinoc.domain.artifacts import Artifact, ArtifactType
32
+ from cinoc.domain.errors import AdapterStepError
33
+ from cinoc.pipeline.protocols import ParamValue
34
+ from cinoc.pipeline.run_control import RunControl
35
+ from cinoc.pipeline.types import RunContext, StepOutput
36
+
37
+ _VERSION = "1.0"
38
+ _DEFAULT_MIN_SCORE = 0.5
39
+
40
+
41
+ #: Modèle PP-DocLayout par défaut. La variante **légère** ``PP-DocLayout-S``
42
+ #: (poids ~10× plus petits) est bakable dans l'image Space pour **tester
43
+ #: l'option** sans peser sur le free-tier ; ``-L`` reste le défaut qualité.
44
+ #: Surchargé par ``CINOC_PPDOCLAYOUT_MODEL`` (lu par le builder) ou ``model=``.
45
+ _DEFAULT_MODEL = "PP-DocLayout-L"
46
+
47
+
48
+ def _detect_with_paddle( # pragma: no cover -- SDK + poids requis (test 'live')
49
+ image_path: str, model: str = _DEFAULT_MODEL
50
+ ) -> LayoutDetection:
51
+ """Détecte la mise en page via **PaddleX PP-DocLayout**. Isolé → mockable.
52
+
53
+ SDK absent → ``AdapterStepError`` explicite (l'extra ``[segment]`` manque) ;
54
+ le module reste listable, il ne plante pas à l'import. Le corps qui exécute le
55
+ modèle n'est exercé que par un test ``live`` (SDK + poids installés). ``model``
56
+ choisit la variante (``-L`` qualité / ``-S`` légère)."""
57
+ try:
58
+ from paddlex import create_model # type: ignore[import-not-found]
59
+ except ImportError as exc:
60
+ raise AdapterStepError(
61
+ "pp_doclayout : PaddleX non installé "
62
+ "(pip install 'cinoc[segment]' + poids PP-DocLayout)."
63
+ ) from exc
64
+ results = list(create_model(model).predict(image_path, batch_size=1))
65
+ if not results:
66
+ return LayoutDetection(page_width=0, page_height=0, regions=())
67
+ boxes = results[0].get("boxes", [])
68
+ regions = tuple(
69
+ DetectedRegion(
70
+ label=str(box.get("label", "")),
71
+ x=int(box["coordinate"][0]),
72
+ y=int(box["coordinate"][1]),
73
+ width=int(box["coordinate"][2]) - int(box["coordinate"][0]),
74
+ height=int(box["coordinate"][3]) - int(box["coordinate"][1]),
75
+ score=float(box.get("score", 0.0)),
76
+ )
77
+ for box in boxes
78
+ )
79
+ shape = results[0].get("input_img_shape") or (0, 0)
80
+ return LayoutDetection(
81
+ page_width=int(shape[1]), page_height=int(shape[0]), regions=regions
82
+ )
83
+
84
+
85
+ class PPDocLayoutSegmenter:
86
+ """Segmenteur PP-DocLayout : ``IMAGE → LAYOUT`` (régions sans lignes)."""
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ model: str = _DEFAULT_MODEL,
92
+ min_score: float = _DEFAULT_MIN_SCORE,
93
+ detector: DetectorFn | None = None,
94
+ ) -> None:
95
+ if not 0.0 <= min_score <= 1.0:
96
+ raise AdapterStepError(
97
+ f"PPDocLayoutSegmenter : min_score ∈ [0, 1], reçu {min_score}."
98
+ )
99
+ self._model = model
100
+ self._min_score = min_score
101
+ self._detector = detector
102
+
103
+ @property
104
+ def name(self) -> str:
105
+ return "pp_doclayout"
106
+
107
+ @property
108
+ def version(self) -> str:
109
+ return _VERSION
110
+
111
+ @property
112
+ def input_types(self) -> frozenset[ArtifactType]:
113
+ return frozenset({ArtifactType.IMAGE})
114
+
115
+ @property
116
+ def output_types(self) -> frozenset[ArtifactType]:
117
+ return frozenset({ArtifactType.LAYOUT})
118
+
119
+ def _detect(self, image_path: str) -> LayoutDetection:
120
+ if self._detector is not None:
121
+ return self._detector(image_path)
122
+ return _detect_with_paddle(image_path, self._model)
123
+
124
+ def execute(
125
+ self,
126
+ inputs: dict[ArtifactType, Artifact],
127
+ params: dict[str, ParamValue],
128
+ context: RunContext,
129
+ control: RunControl,
130
+ ) -> StepOutput:
131
+ control.raise_if_cancelled()
132
+ image = inputs.get(ArtifactType.IMAGE)
133
+ if image is None or image.uri is None:
134
+ raise AdapterStepError(
135
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
136
+ )
137
+ if context.workspace_uri is None:
138
+ raise AdapterStepError(
139
+ f"{self.name} : workspace requis (RunContext.workspace_uri)."
140
+ )
141
+ layout = to_canonical_layout(
142
+ self._detect(image.uri), min_score=self._min_score
143
+ )
144
+ return layout_step_output(layout, context, self.name)
145
+
146
+
147
+ __all__ = ["PPDocLayoutSegmenter"]
cinoc/adapters/layout/precomputed.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Socle ``precomputed`` de la tranche segmentation (couche 5, 0 dép, 0 réseau).
2
+
3
+ Deux briques, **même ``Module`` Protocol** que les autres adapters, qui font
4
+ tourner le squelette segmentation **sans segmenteur ni OCR réels** :
5
+
6
+ - ``PrecomputedLayoutSource`` : ``IMAGE → LAYOUT`` — rejoue une mise en page
7
+ pré-calculée (régions seules) depuis ``<stem>.layout.json``.
8
+ - ``PrecomputedRegionRecognizer`` : ``IMAGE → RAW_TEXT`` — rejoue le texte d'**une
9
+ région** (clé = ``Artifact.region_id`` de l'IMAGE d'entrée) depuis
10
+ ``<stem>.<label>.regions.json`` (``{region_id: texte}``). Appelé **une fois par
11
+ région** par le fan-out (couche 4) ; aucun découpage d'image réel (la donnée
12
+ est figée).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from pathlib import Path
19
+
20
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
21
+ from cinoc.domain.errors import AdapterStepError
22
+ from cinoc.domain.layout import CanonicalLayout
23
+ from cinoc.pipeline.protocols import ParamValue
24
+ from cinoc.pipeline.run_control import RunControl
25
+ from cinoc.pipeline.types import RunContext, StepOutput
26
+
27
+ _VERSION = "1.0"
28
+
29
+
30
+ def _require_image(inputs: dict[ArtifactType, Artifact], who: str) -> Path:
31
+ image = inputs.get(ArtifactType.IMAGE)
32
+ if image is None or image.uri is None:
33
+ raise AdapterStepError(f"{who} : artefact IMAGE manquant ou sans URI.")
34
+ return Path(image.uri)
35
+
36
+
37
+ class PrecomputedLayoutSource:
38
+ """Rejoue une mise en page pré-calculée (``<stem>.layout.json``)."""
39
+
40
+ @property
41
+ def name(self) -> str:
42
+ return "precomputed_layout"
43
+
44
+ @property
45
+ def version(self) -> str:
46
+ return _VERSION
47
+
48
+ @property
49
+ def input_types(self) -> frozenset[ArtifactType]:
50
+ return frozenset({ArtifactType.IMAGE})
51
+
52
+ @property
53
+ def output_types(self) -> frozenset[ArtifactType]:
54
+ return frozenset({ArtifactType.LAYOUT})
55
+
56
+ def execute(
57
+ self,
58
+ inputs: dict[ArtifactType, Artifact],
59
+ params: dict[str, ParamValue],
60
+ context: RunContext,
61
+ control: RunControl,
62
+ ) -> StepOutput:
63
+ control.raise_if_cancelled()
64
+ image_path = _require_image(inputs, self.name)
65
+ layout_path = image_path.parent / f"{image_path.stem}.layout.json"
66
+ if not layout_path.is_file():
67
+ raise AdapterStepError(
68
+ f"{self.name} : mise en page pré-calculée introuvable — "
69
+ f"{layout_path.name!r} attendu près de {image_path.name!r}."
70
+ )
71
+ data = layout_path.read_bytes()
72
+ try:
73
+ CanonicalLayout.model_validate_json(data)
74
+ except ValueError as exc:
75
+ raise AdapterStepError(
76
+ f"{self.name} : {layout_path.name!r} n'est pas un "
77
+ f"CanonicalLayout valide : {exc}"
78
+ ) from exc
79
+ return StepOutput(
80
+ artifacts={
81
+ ArtifactType.LAYOUT: Artifact(
82
+ id=f"{context.document_id}:{self.name}:layout",
83
+ document_id=context.document_id,
84
+ type=ArtifactType.LAYOUT,
85
+ uri=str(layout_path),
86
+ content_hash=compute_content_hash(data),
87
+ )
88
+ }
89
+ )
90
+
91
+
92
+ class PrecomputedRegionRecognizer:
93
+ """Rejoue le texte d'une région (``<stem>.<label>.regions.json``)."""
94
+
95
+ def __init__(self, *, source_label: str) -> None:
96
+ if not source_label or not all(
97
+ c.isalnum() or c in "_-" for c in source_label
98
+ ):
99
+ raise AdapterStepError(
100
+ f"PrecomputedRegionRecognizer : source_label invalide "
101
+ f"{source_label!r} (alphanumérique + _ - uniquement)."
102
+ )
103
+ self._label = source_label
104
+
105
+ @property
106
+ def name(self) -> str:
107
+ return f"precomputed_region:{self._label}"
108
+
109
+ @property
110
+ def version(self) -> str:
111
+ return _VERSION
112
+
113
+ @property
114
+ def input_types(self) -> frozenset[ArtifactType]:
115
+ return frozenset({ArtifactType.IMAGE})
116
+
117
+ @property
118
+ def output_types(self) -> frozenset[ArtifactType]:
119
+ return frozenset({ArtifactType.RAW_TEXT})
120
+
121
+ def execute(
122
+ self,
123
+ inputs: dict[ArtifactType, Artifact],
124
+ params: dict[str, ParamValue],
125
+ context: RunContext,
126
+ control: RunControl,
127
+ ) -> StepOutput:
128
+ control.raise_if_cancelled()
129
+ image = inputs[ArtifactType.IMAGE]
130
+ if image.region_id is None:
131
+ raise AdapterStepError(
132
+ f"{self.name} : IMAGE sans region_id — la reconnaissance par "
133
+ "région attend une entrée déjà cadrée par le fan-out."
134
+ )
135
+ image_path = _require_image(inputs, self.name)
136
+ regions_path = (
137
+ image_path.parent / f"{image_path.stem}.{self._label}.regions.json"
138
+ )
139
+ if not regions_path.is_file():
140
+ raise AdapterStepError(
141
+ f"{self.name} : textes par région introuvables — "
142
+ f"{regions_path.name!r} attendu près de {image_path.name!r}."
143
+ )
144
+ try:
145
+ mapping = json.loads(regions_path.read_bytes())
146
+ except ValueError as exc:
147
+ raise AdapterStepError(
148
+ f"{self.name} : {regions_path.name!r} JSON invalide : {exc}"
149
+ ) from exc
150
+ if not isinstance(mapping, dict) or image.region_id not in mapping:
151
+ raise AdapterStepError(
152
+ f"{self.name} : région {image.region_id!r} absente de "
153
+ f"{regions_path.name!r}."
154
+ )
155
+ text = str(mapping[image.region_id])
156
+ out_dir = (
157
+ Path(context.workspace_uri)
158
+ if context.workspace_uri
159
+ else image_path.parent
160
+ )
161
+ out_path = out_dir / f"{image_path.stem}.{self._label}.{image.region_id}.txt"
162
+ payload = text.encode("utf-8")
163
+ out_path.write_bytes(payload)
164
+ return StepOutput(
165
+ artifacts={
166
+ ArtifactType.RAW_TEXT: Artifact(
167
+ id=f"{context.document_id}:{self.name}:{image.region_id}:raw_text",
168
+ document_id=context.document_id,
169
+ type=ArtifactType.RAW_TEXT,
170
+ uri=str(out_path),
171
+ content_hash=compute_content_hash(payload),
172
+ region_id=image.region_id,
173
+ )
174
+ }
175
+ )
176
+
177
+
178
+ __all__ = ["PrecomputedLayoutSource", "PrecomputedRegionRecognizer"]
cinoc/adapters/layout/remote.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``RemoteSegmenter`` — segmenteur de mise en page **par appel distant**.
2
+
3
+ Au lieu d'embarquer un modèle lourd (poids dans l'image), ce module **délègue** la
4
+ détection de régions à un modèle hébergé ailleurs : un *Inference Endpoint* HF, un
5
+ Space-modèle, ou tout service exposant l'API **object-detection** de Hugging Face.
6
+ Cinoc **orchestre**, le modèle **tourne à distance** — le Space reste léger (pas de
7
+ poids, pas d'inférence CPU locale).
8
+
9
+ C'est l'incarnation « distante » du point d'extension unique de Cinoc : il
10
+ implémente le **même ``Module`` Protocol** que ``PPDocLayoutSegmenter`` (et que les
11
+ plugins tiers), donc il apparaît comme **segmenteur sélectionnable** avant un run.
12
+ On change de modèle en changeant l'``endpoint`` — sans rien réinstaller.
13
+
14
+ Schéma de réponse attendu = sortie **object-detection** HF :
15
+ ``[{"label": str, "score": float, "box": {"xmin","ymin","xmax","ymax"}}, …]``.
16
+
17
+ Le détecteur est **injectable** (``detector=``) : la conversion détections →
18
+ ``CanonicalLayout`` se teste sans réseau ; le vrai appel HTTP (``httpx`` + garde
19
+ **anti-SSRF** ``assert_public_url``) n'est exercé qu'en test ``live``. ``httpx`` est
20
+ un extra optionnel (``cinoc[serve]``/``[google]``…), importé paresseusement.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+
27
+ from cinoc.adapters.corpus._http import SsrfError, assert_public_url
28
+ from cinoc.adapters.layout._base import (
29
+ DetectedRegion,
30
+ DetectorFn,
31
+ LayoutDetection,
32
+ layout_step_output,
33
+ to_canonical_layout,
34
+ )
35
+ from cinoc.domain.artifacts import Artifact, ArtifactType
36
+ from cinoc.domain.errors import AdapterStepError
37
+ from cinoc.pipeline.protocols import ParamValue
38
+ from cinoc.pipeline.run_control import RunControl
39
+ from cinoc.pipeline.types import RunContext, StepOutput
40
+
41
+ _VERSION = "1.0"
42
+ _DEFAULT_MIN_SCORE = 0.5
43
+ _DEFAULT_TIMEOUT = 60.0
44
+
45
+
46
+ def parse_hf_detections(
47
+ payload: object, *, page_width: int, page_height: int
48
+ ) -> LayoutDetection:
49
+ """Réponse object-detection HF → ``LayoutDetection`` (tolérante aux entrées
50
+ mal formées : les boîtes invalides sont ignorées, jamais une exception)."""
51
+ if not isinstance(payload, list):
52
+ raise AdapterStepError(
53
+ "remote_segmenter : réponse inattendue (liste de détections attendue)."
54
+ )
55
+ regions: list[DetectedRegion] = []
56
+ for item in payload:
57
+ if not isinstance(item, dict):
58
+ continue
59
+ box = item.get("box")
60
+ if not isinstance(box, dict):
61
+ continue
62
+ try:
63
+ x0, y0 = int(box["xmin"]), int(box["ymin"])
64
+ x1, y1 = int(box["xmax"]), int(box["ymax"])
65
+ except (KeyError, TypeError, ValueError):
66
+ continue
67
+ regions.append(
68
+ DetectedRegion(
69
+ label=str(item.get("label", "")),
70
+ x=x0,
71
+ y=y0,
72
+ width=max(0, x1 - x0),
73
+ height=max(0, y1 - y0),
74
+ score=float(item.get("score", 0.0)),
75
+ )
76
+ )
77
+ return LayoutDetection(
78
+ page_width=page_width, page_height=page_height, regions=tuple(regions)
79
+ )
80
+
81
+
82
+ def _image_dims(image_path: str) -> tuple[int, int]:
83
+ """Dimensions ``(largeur, hauteur)`` de l'image ; ``(0, 0)`` si indisponible
84
+ (Pillow absent / illisible) — la conversion gère les dimensions nulles."""
85
+ try:
86
+ from PIL import Image, UnidentifiedImageError
87
+ except ImportError:
88
+ return (0, 0)
89
+ try:
90
+ with Image.open(image_path) as opened:
91
+ return (int(opened.width), int(opened.height))
92
+ except (UnidentifiedImageError, OSError):
93
+ return (0, 0)
94
+
95
+
96
+ class RemoteSegmenter:
97
+ """Segmenteur ``IMAGE → LAYOUT`` délégué à un endpoint object-detection."""
98
+
99
+ def __init__(
100
+ self,
101
+ *,
102
+ endpoint: str,
103
+ token: str | None = None,
104
+ min_score: float = _DEFAULT_MIN_SCORE,
105
+ timeout: float = _DEFAULT_TIMEOUT,
106
+ detector: DetectorFn | None = None,
107
+ ) -> None:
108
+ if not endpoint:
109
+ raise AdapterStepError("remote_segmenter : 'endpoint' requis.")
110
+ if not 0.0 <= min_score <= 1.0:
111
+ raise AdapterStepError(
112
+ f"remote_segmenter : min_score ∈ [0, 1], reçu {min_score}."
113
+ )
114
+ self._endpoint = endpoint
115
+ self._token = token
116
+ self._min_score = min_score
117
+ self._timeout = timeout
118
+ self._detector = detector
119
+
120
+ @property
121
+ def name(self) -> str:
122
+ return "remote_segmenter"
123
+
124
+ @property
125
+ def version(self) -> str:
126
+ return _VERSION
127
+
128
+ @property
129
+ def input_types(self) -> frozenset[ArtifactType]:
130
+ return frozenset({ArtifactType.IMAGE})
131
+
132
+ @property
133
+ def output_types(self) -> frozenset[ArtifactType]:
134
+ return frozenset({ArtifactType.LAYOUT})
135
+
136
+ def _detect(self, image_path: str) -> LayoutDetection:
137
+ if self._detector is not None:
138
+ return self._detector(image_path)
139
+ return self._remote_detect(image_path)
140
+
141
+ def _remote_detect( # pragma: no cover -- réseau réel (test 'live')
142
+ self, image_path: str
143
+ ) -> LayoutDetection:
144
+ try:
145
+ assert_public_url(self._endpoint)
146
+ except SsrfError as exc:
147
+ raise AdapterStepError(
148
+ f"remote_segmenter : endpoint refusé (anti-SSRF) : {exc}"
149
+ ) from exc
150
+ try:
151
+ import httpx
152
+ except ImportError as exc:
153
+ raise AdapterStepError(
154
+ "remote_segmenter : httpx absent (extra réseau requis)."
155
+ ) from exc
156
+ headers = {"Content-Type": "application/octet-stream"}
157
+ if self._token:
158
+ headers["Authorization"] = f"Bearer {self._token}"
159
+ try:
160
+ with httpx.Client(timeout=self._timeout) as client:
161
+ response = client.post(
162
+ self._endpoint,
163
+ content=Path(image_path).read_bytes(),
164
+ headers=headers,
165
+ )
166
+ response.raise_for_status()
167
+ payload = response.json()
168
+ except httpx.HTTPError as exc:
169
+ raise AdapterStepError(
170
+ f"remote_segmenter : appel à l'endpoint échoué : {exc}"
171
+ ) from exc
172
+ width, height = _image_dims(image_path)
173
+ return parse_hf_detections(payload, page_width=width, page_height=height)
174
+
175
+ def execute(
176
+ self,
177
+ inputs: dict[ArtifactType, Artifact],
178
+ params: dict[str, ParamValue],
179
+ context: RunContext,
180
+ control: RunControl,
181
+ ) -> StepOutput:
182
+ control.raise_if_cancelled()
183
+ image = inputs.get(ArtifactType.IMAGE)
184
+ if image is None or image.uri is None:
185
+ raise AdapterStepError(
186
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
187
+ )
188
+ layout = to_canonical_layout(
189
+ self._detect(image.uri), min_score=self._min_score
190
+ )
191
+ return layout_step_output(layout, context, self.name)
192
+
193
+
194
+ __all__ = ["RemoteSegmenter", "parse_hf_detections"]
cinoc/adapters/layout/saknussemm_correct.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``SaknussemmCorrector`` — ``LAYOUT → LAYOUT + CORRECTED_TEXT`` (couche 5).
2
+
3
+ La post-correction du banc était **texte plat → texte plat** : on aplatissait
4
+ avant de corriger, donc l'identité de ligne, la géométrie et la césure étaient
5
+ détruites *avant* que le modèle voie quoi que ce soit. Cette étape corrige
6
+ **dans** la mise en page : chaque ligne garde son identifiant d'un bout à
7
+ l'autre, et l'appariement avant/après est **connu** au lieu d'être deviné.
8
+
9
+ Deux sorties, et c'est délibéré :
10
+
11
+ * ``LAYOUT`` — la mise en page corrigée, que ``alto_assembler`` sait rendre en
12
+ ALTO et que les métriques de structure savent noter ;
13
+ * ``CORRECTED_TEXT`` — le même contenu aplati, **pour que le bilan de correction
14
+ existant fonctionne sans être modifié**. Il cherche un ``RAW_TEXT`` et un
15
+ ``CORRECTED_TEXT`` dans les sorties du pipeline ; les lui donner coûte une
16
+ projection et évite d'inventer un type d'artefact pour ça ;
17
+ * ``DECISIONS`` — ce qui a été changé, ce qui a été **refusé**, et pourquoi.
18
+ Un texte corrigé ne dit pas si une ligne est intacte parce qu'une garde l'a
19
+ protégée ou parce que rien n'a été proposé : deux situations que rien ne
20
+ distingue une fois le texte écrit.
21
+
22
+ Ce que la bibliothèque décide, l'étape le rapporte sans le retoucher : une ligne
23
+ refusée par une garde ressort **avec son texte d'origine**. C'est le principe de
24
+ `saknussemm` — l'application décide, le modèle informe — et le contredire ici
25
+ reviendrait à réintroduire en aval ce que les gardes ont écarté.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from cinoc.adapters._workspace import workspace_artifact_path
35
+ from cinoc.adapters.layout.to_text import _page_text
36
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
37
+ from cinoc.domain.errors import AdapterStepError
38
+ from cinoc.domain.layout import CanonicalLayout, Line, Region
39
+ from cinoc.pipeline.protocols import ParamValue
40
+ from cinoc.pipeline.run_control import RunControl
41
+ from cinoc.pipeline.types import RunContext, StepOutput
42
+
43
+ _VERSION = "1.0"
44
+
45
+ #: Producteurs câblés. ``rules`` ne demande rien (déterministe, hors ligne) ;
46
+ #: ``ollama`` parle à un serveur local, sans clé d'API. Les fournisseurs
47
+ #: distants s'ajouteront quand un run les demandera — pas avant.
48
+ _PRODUCERS = ("rules", "ollama")
49
+
50
+
51
+ def _require_saknussemm() -> Any:
52
+ try:
53
+ import saknussemm # type: ignore[import-not-found] # noqa: PLC0415
54
+ except ImportError as exc: # pragma: no cover - dépend de l'installation
55
+ raise AdapterStepError(
56
+ "saknussemm_correct : la bibliothèque 'saknussemm' n'est pas "
57
+ "installée — `pip install cinoc[saknussemm]`."
58
+ ) from exc
59
+ return saknussemm
60
+
61
+
62
+ class SaknussemmCorrector:
63
+ """Corrige un ``CanonicalLayout`` ligne à ligne, identités préservées."""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ label: str,
69
+ producer: str = "rules",
70
+ model: str = "",
71
+ host: str = "http://localhost:11434",
72
+ ) -> None:
73
+ if producer not in _PRODUCERS:
74
+ raise AdapterStepError(
75
+ f"SaknussemmCorrector : producteur {producer!r} inconnu "
76
+ f"(attendu : {', '.join(_PRODUCERS)})."
77
+ )
78
+ if producer == "ollama" and not model:
79
+ raise AdapterStepError(
80
+ "SaknussemmCorrector : le producteur 'ollama' exige un `model`."
81
+ )
82
+ self._label = label
83
+ self._producer = producer
84
+ self._model = model
85
+ self._host = host
86
+
87
+ @property
88
+ def name(self) -> str:
89
+ return f"saknussemm:{self._label}"
90
+
91
+ @property
92
+ def version(self) -> str:
93
+ return _VERSION
94
+
95
+ @property
96
+ def input_types(self) -> frozenset[ArtifactType]:
97
+ return frozenset({ArtifactType.LAYOUT})
98
+
99
+ @property
100
+ def output_types(self) -> frozenset[ArtifactType]:
101
+ return frozenset(
102
+ {
103
+ ArtifactType.LAYOUT,
104
+ ArtifactType.CORRECTED_TEXT,
105
+ ArtifactType.DECISIONS,
106
+ }
107
+ )
108
+
109
+ # -- producteur ---------------------------------------------------------
110
+
111
+ def _build_producer(self) -> Any:
112
+ if self._producer == "rules":
113
+ from saknussemm.producers.rules import ( # type: ignore[import-not-found] # noqa: PLC0415
114
+ RulesProducer,
115
+ default_french_ocr_rules,
116
+ )
117
+
118
+ return RulesProducer(default_french_ocr_rules())
119
+
120
+ from saknussemm.producers.llm_edit import ( # type: ignore[import-not-found] # noqa: PLC0415, E501
121
+ LLMEditProducer,
122
+ )
123
+
124
+ from cinoc.adapters.llm.ollama_structured import ( # noqa: PLC0415
125
+ OllamaStructuredClient,
126
+ )
127
+
128
+ return LLMEditProducer(
129
+ OllamaStructuredClient(host=self._host), api_key="", model=self._model
130
+ )
131
+
132
+ # -- exécution ----------------------------------------------------------
133
+
134
+ def execute(
135
+ self,
136
+ inputs: dict[ArtifactType, Artifact],
137
+ params: dict[str, ParamValue], # noqa: ARG002 — contrat Module
138
+ context: RunContext,
139
+ control: RunControl,
140
+ ) -> StepOutput:
141
+ control.raise_if_cancelled()
142
+ artifact = inputs.get(ArtifactType.LAYOUT)
143
+ if artifact is None or artifact.uri is None:
144
+ raise AdapterStepError(f"{self.name} : artefact LAYOUT sans URI.")
145
+ try:
146
+ layout = CanonicalLayout.model_validate_json(
147
+ Path(artifact.uri).read_bytes()
148
+ )
149
+ except (OSError, ValueError) as exc:
150
+ raise AdapterStepError(
151
+ f"{self.name} : {artifact.uri!r} n'est pas un CanonicalLayout "
152
+ f"lisible — {exc}"
153
+ ) from exc
154
+ _require_saknussemm()
155
+
156
+ from saknussemm.core.pipeline import ( # type: ignore[import-not-found] # noqa: PLC0415, E501
157
+ CorrectionPipeline,
158
+ )
159
+
160
+ from cinoc.adapters.layout._saknussemm_bridge import ( # noqa: PLC0415
161
+ layout_to_manifest,
162
+ manifest_page_ids,
163
+ )
164
+
165
+ manifest = layout_to_manifest(layout, document_id=context.document_id)
166
+ control.raise_if_cancelled()
167
+
168
+ class _Observer:
169
+ def on_event(self, event_type: str, payload: dict[str, Any]) -> None:
170
+ pass
171
+
172
+ pipeline = CorrectionPipeline(
173
+ producer=self._build_producer(), observer=_Observer()
174
+ )
175
+ # ``source_files`` vide : on ne réécrit aucun XML. L'artefact de sortie
176
+ # est la mise en page, et ``alto_assembler`` sait en faire un ALTO — le
177
+ # moteur n'a donc rien à rendre lui-même.
178
+ result = pipeline.run_sync(document_manifest=manifest, source_files={})
179
+
180
+ decided = {
181
+ (outcome.page_id, outcome.line_id): outcome.decision.final_text
182
+ for outcome in result.report.lines
183
+ }
184
+ corrected = _apply(layout, decided, manifest_page_ids(manifest))
185
+ return self._emit(corrected, _decisions(result, context.document_id), context)
186
+
187
+ def _emit(
188
+ self, layout: CanonicalLayout, decisions: bytes, context: RunContext
189
+ ) -> StepOutput:
190
+ payload = layout.model_dump_json().encode("utf-8")
191
+ text = _flatten(layout).encode("utf-8")
192
+ layout_path = self._out(context, "layout.json")
193
+ text_path = self._out(context, "corrected.txt")
194
+ decisions_path = self._out(context, "decisions.json")
195
+ layout_path.parent.mkdir(parents=True, exist_ok=True)
196
+ layout_path.write_bytes(payload)
197
+ text_path.write_bytes(text)
198
+ decisions_path.write_bytes(decisions)
199
+ return StepOutput(
200
+ artifacts={
201
+ ArtifactType.LAYOUT: Artifact(
202
+ id=f"{context.document_id}:{self.name}:layout",
203
+ document_id=context.document_id,
204
+ type=ArtifactType.LAYOUT,
205
+ uri=str(layout_path),
206
+ content_hash=compute_content_hash(payload),
207
+ ),
208
+ ArtifactType.CORRECTED_TEXT: Artifact(
209
+ id=f"{context.document_id}:{self.name}:text",
210
+ document_id=context.document_id,
211
+ type=ArtifactType.CORRECTED_TEXT,
212
+ uri=str(text_path),
213
+ content_hash=compute_content_hash(text),
214
+ ),
215
+ ArtifactType.DECISIONS: Artifact(
216
+ id=f"{context.document_id}:{self.name}:decisions",
217
+ document_id=context.document_id,
218
+ type=ArtifactType.DECISIONS,
219
+ uri=str(decisions_path),
220
+ content_hash=compute_content_hash(decisions),
221
+ ),
222
+ }
223
+ )
224
+
225
+ def _out(self, context: RunContext, suffix: str) -> Path:
226
+ if context.workspace_uri:
227
+ return workspace_artifact_path(
228
+ context.workspace_uri, context.document_id, self._label, suffix
229
+ )
230
+ return Path(f"{context.document_id.replace('/', '_')}.{self._label}.{suffix}")
231
+
232
+
233
+ def _decisions(result: object, document_id: str) -> bytes:
234
+ """Sérialise ce que la bibliothèque a décidé, ligne par ligne.
235
+
236
+ Le **statut** distingue trois situations qu'un texte corrigé confond :
237
+ la ligne a changé, une garde a refusé un changement proposé, ou rien n'a
238
+ été proposé. Sans cette distinction, un correcteur prudent et un correcteur
239
+ inerte rendent le même artefact.
240
+ """
241
+ lignes = []
242
+ for outcome in result.report.lines: # type: ignore[attr-defined]
243
+ decision = outcome.decision
244
+ propose = outcome.proposal.output_text if outcome.proposal else None
245
+ raison = decision.reason
246
+ lignes.append(
247
+ {
248
+ "document_id": document_id,
249
+ "page_id": outcome.page_id,
250
+ "line_id": outcome.line_id,
251
+ "status": decision.status,
252
+ "source_text": outcome.source_text,
253
+ "final_text": decision.final_text,
254
+ "proposed_text": propose,
255
+ "hyphen_role": outcome.hyphen_role,
256
+ "reason_code": raison.code if raison else None,
257
+ "reason_detail": raison.detail if raison else None,
258
+ }
259
+ )
260
+ return json.dumps(
261
+ {"document_id": document_id, "lines": lignes}, ensure_ascii=False
262
+ ).encode("utf-8")
263
+
264
+
265
+ def _apply(
266
+ layout: CanonicalLayout,
267
+ decided: dict[tuple[str, str], str],
268
+ page_ids: list[str],
269
+ ) -> CanonicalLayout:
270
+ """Repose les textes décidés sur la mise en page, identités à l'appui.
271
+
272
+ Les mots sont **abandonnés sur une ligne modifiée** : leur géométrie décrit
273
+ des caractères qui ne sont plus là. Les garder ferait dire à l'artefact une
274
+ position que rien ne soutient — le contraire de ce que la structure sert à
275
+ porter.
276
+ """
277
+
278
+ def region(reg: Region, page_id: str) -> Region:
279
+ lines = []
280
+ for line in reg.lines:
281
+ texte = decided.get((page_id, line.id or ""))
282
+ if texte is None or texte == line.text:
283
+ lines.append(line)
284
+ continue
285
+ lines.append(
286
+ Line(
287
+ id=line.id,
288
+ text=texte,
289
+ geometry=line.geometry,
290
+ baseline=line.baseline,
291
+ words=(),
292
+ confidence=line.confidence,
293
+ )
294
+ )
295
+ return reg.model_copy(
296
+ update={
297
+ "lines": tuple(lines),
298
+ "regions": tuple(region(r, page_id) for r in reg.regions),
299
+ }
300
+ )
301
+
302
+ pages = tuple(
303
+ page.model_copy(
304
+ update={"regions": tuple(region(r, page_ids[i]) for r in page.regions)}
305
+ )
306
+ for i, page in enumerate(layout.pages)
307
+ )
308
+ return layout.model_copy(update={"pages": pages})
309
+
310
+
311
+ def _flatten(layout: CanonicalLayout) -> str:
312
+ """Aplatit **avec la fonction de ``to_text``**, pas avec une copie.
313
+
314
+ Le bilan de correction compare le ``RAW_TEXT`` produit par ``to_text`` au
315
+ ``CORRECTED_TEXT`` produit ici : deux conventions d'aplatissement
316
+ différentes fausseraient la comparaison sans rien casser de visible. Une
317
+ première version réécrivait la boucle et ajoutait 41 lignes vides sur le
318
+ corpus BnF — les régions sans texte — ce qui aurait décalé tout
319
+ l'appariement de lignes.
320
+ """
321
+ return "\n".join(_page_text(page) for page in layout.pages)
322
+
323
+
324
+ __all__ = ["SaknussemmCorrector"]
cinoc/adapters/layout/to_text.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``LayoutToTextExtractor`` — ``LAYOUT rempli → RAW_TEXT`` (couche 5).
2
+
3
+ Quatrième étage **optionnel** d'un pipeline hybride : une fois les régions
4
+ reconnues (fan-out → ``CanonicalLayout`` rempli), on aplatit le texte dans
5
+ **l'ordre de lecture** pour le rendre **scorable** comme n'importe quelle sortie
6
+ ``RAW_TEXT`` (CER/WER à la page). C'est ce qui permet à un run « segmenteur →
7
+ reconnaissance par bloc » d'être un **concurrent du Banc d'essai** comparé à un
8
+ OCR à plat, et plus seulement un ALTO produit dans son coin.
9
+
10
+ Déterministe : ordre = ``reading_order`` de la page (repli sur l'ordre déclaré),
11
+ lignes d'une région jointes par ``\\n``, régions par ``\\n``, pages par ``\\n``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
19
+ from cinoc.domain.errors import AdapterStepError
20
+ from cinoc.domain.layout import CanonicalLayout, LayoutPage
21
+ from cinoc.pipeline.protocols import ParamValue
22
+ from cinoc.pipeline.run_control import RunControl
23
+ from cinoc.pipeline.types import RunContext, StepOutput
24
+
25
+ _VERSION = "1.0"
26
+
27
+
28
+ def _page_text(page: LayoutPage) -> str:
29
+ """Texte d'une page, régions dans l'ordre de lecture (repli ordre déclaré)."""
30
+ by_id = {region.id: region for region in page.regions}
31
+ ordered = [by_id[rid] for rid in page.reading_order if rid in by_id]
32
+ seen = {region.id for region in ordered}
33
+ ordered.extend(region for region in page.regions if region.id not in seen)
34
+ blocks: list[str] = []
35
+ for region in ordered:
36
+ lines = "\n".join(line.text for line in region.lines if line.text)
37
+ if lines:
38
+ blocks.append(lines)
39
+ return "\n".join(blocks)
40
+
41
+
42
+ class LayoutToTextExtractor:
43
+ """Aplatit un ``LAYOUT`` rempli en ``RAW_TEXT`` (ordre de lecture, déterministe)."""
44
+
45
+ def __init__(self, label: str) -> None:
46
+ self._label = label
47
+
48
+ @property
49
+ def name(self) -> str:
50
+ return f"layout_to_text:{self._label}"
51
+
52
+ @property
53
+ def version(self) -> str:
54
+ return _VERSION
55
+
56
+ @property
57
+ def input_types(self) -> frozenset[ArtifactType]:
58
+ return frozenset({ArtifactType.LAYOUT})
59
+
60
+ @property
61
+ def output_types(self) -> frozenset[ArtifactType]:
62
+ return frozenset({ArtifactType.RAW_TEXT})
63
+
64
+ def execute(
65
+ self,
66
+ inputs: dict[ArtifactType, Artifact],
67
+ params: dict[str, ParamValue],
68
+ context: RunContext,
69
+ control: RunControl,
70
+ ) -> StepOutput:
71
+ control.raise_if_cancelled()
72
+ layout_art = inputs.get(ArtifactType.LAYOUT)
73
+ if layout_art is None or layout_art.uri is None:
74
+ raise AdapterStepError(
75
+ f"{self.name} : artefact LAYOUT manquant ou sans URI."
76
+ )
77
+ layout_path = Path(layout_art.uri)
78
+ try:
79
+ layout = CanonicalLayout.model_validate_json(layout_path.read_bytes())
80
+ except (OSError, ValueError) as exc:
81
+ raise AdapterStepError(
82
+ f"{self.name} : LAYOUT illisible ({layout_path.name!r}) : {exc}"
83
+ ) from exc
84
+ text = "\n".join(_page_text(page) for page in layout.pages)
85
+ payload = text.encode("utf-8")
86
+ out_dir = (
87
+ Path(context.workspace_uri)
88
+ if context.workspace_uri
89
+ else layout_path.parent
90
+ )
91
+ out_path = out_dir / f"{context.document_id.replace('/', '_')}.layout.txt"
92
+ out_path.write_bytes(payload)
93
+ return StepOutput(
94
+ artifacts={
95
+ ArtifactType.RAW_TEXT: Artifact(
96
+ id=f"{context.document_id}:{self.name}:raw_text",
97
+ document_id=context.document_id,
98
+ type=ArtifactType.RAW_TEXT,
99
+ uri=str(out_path),
100
+ content_hash=compute_content_hash(payload),
101
+ )
102
+ }
103
+ )
104
+
105
+
106
+ __all__ = ["LayoutToTextExtractor"]
cinoc/adapters/llm/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adapters LLM/VLM (couche 5) — post-correction texte/image + transcription VLM.
2
+
3
+ Modes par adapter (``PipelineMode``) : ``text_only`` (tous), ``text_and_image``
4
+ et ``zero_shot`` (openai, mistral, anthropic — vision). ``ollama`` reste
5
+ ``text_only`` (socle local minimal).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from cinoc.adapters.llm.anthropic import AnthropicAdapter
11
+ from cinoc.adapters.llm.mistral import MistralAdapter
12
+ from cinoc.adapters.llm.ollama import OllamaAdapter
13
+ from cinoc.adapters.llm.openai import OpenAIAdapter
14
+
15
+ __all__ = ["AnthropicAdapter", "MistralAdapter", "OllamaAdapter", "OpenAIAdapter"]
cinoc/adapters/llm/_base.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers partagés des adapters LLM/VLM (post-correction + transcription).
2
+
3
+ Les adapters concrets (openai, anthropic, mistral, ollama) implémentent le
4
+ ``Module`` Protocol et ne fournissent que leurs **appels réseau** (isolés,
5
+ mockables). Toute la logique commune — choix des entrées/sorties selon le
6
+ **mode**, chargement du texte OCR et/ou de l'image, écriture de l'artefact —
7
+ vit **ici**, dans ``run_llm_step`` : un seul endroit, aucun mode dupliqué par
8
+ fournisseur (anti « 8+8+8 », cf. CLAUDE.md §8.8).
9
+
10
+ Modes (``PipelineMode``, source de vérité unique en ``domain``) :
11
+
12
+ - ``text_only`` — ``RAW_TEXT`` → ``CORRECTED_TEXT`` (le LLM ne voit pas l'image).
13
+ - ``text_and_image`` — ``{RAW_TEXT, IMAGE}`` → ``CORRECTED_TEXT`` (le VLM voit l'image).
14
+ - ``zero_shot`` — ``IMAGE`` → ``RAW_TEXT`` (transcription directe, sans OCR amont).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from base64 import b64encode
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import Any, NamedTuple, cast
23
+
24
+ from cinoc.adapters._workspace import workspace_artifact_path
25
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
26
+ from cinoc.domain.errors import AdapterStepError
27
+ from cinoc.domain.pipeline import PipelineMode
28
+ from cinoc.domain.usage import ResourceUsage
29
+ from cinoc.formats.text import read_plaintext
30
+ from cinoc.pipeline.run_control import RunControl
31
+ from cinoc.pipeline.types import RunContext, StepOutput
32
+
33
+ #: Prompt de post-correction (modes ``text_only`` / ``text_and_image``) ;
34
+ #: ``{ocr_text}`` est substitué par le texte OCR amont.
35
+ DEFAULT_CORRECTION_PROMPT = (
36
+ "Tu es un correcteur de transcriptions OCR de documents patrimoniaux. "
37
+ "Corrige uniquement les erreurs manifestes de reconnaissance, sans "
38
+ "reformuler ni moderniser l'orthographe historique. Réponds par le seul "
39
+ "texte corrigé.\n\n{ocr_text}"
40
+ )
41
+
42
+ #: Prompt de transcription directe (mode ``zero_shot`` : le VLM lit l'image).
43
+ DEFAULT_TRANSCRIPTION_PROMPT = (
44
+ "Transcris fidèlement le texte de cette image de document patrimonial. "
45
+ "Conserve l'orthographe historique et la ponctuation d'origine. N'ajoute "
46
+ "aucun commentaire : réponds par le seul texte transcrit."
47
+ )
48
+
49
+ #: Extension de fichier → media type (sous-ensemble image courant).
50
+ _MEDIA_TYPES = {
51
+ ".png": "image/png",
52
+ ".jpg": "image/jpeg",
53
+ ".jpeg": "image/jpeg",
54
+ ".webp": "image/webp",
55
+ ".gif": "image/gif",
56
+ ".tif": "image/tiff",
57
+ ".tiff": "image/tiff",
58
+ }
59
+
60
+ class LLMCompletion(NamedTuple):
61
+ """Réponse d'un fournisseur : texte + jetons consommés (si exposés)."""
62
+
63
+ text: str
64
+ tokens_in: int | None = None
65
+ tokens_out: int | None = None
66
+
67
+
68
+ #: Appel d'un LLM texte : ``prompt → réponse`` (fournisseur + modèle capturés).
69
+ TextInvoke = Callable[[str], LLMCompletion]
70
+ #: Appel d'un VLM : ``prompt, media_type, image_b64 → réponse``.
71
+ VisionInvoke = Callable[[str, str, str], LLMCompletion]
72
+
73
+
74
+ def usage_tokens(value: Any) -> int | None:
75
+ """Coercition prudente d'un compteur de jetons SDK (``None`` si absent)."""
76
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
77
+ return None
78
+ return value
79
+
80
+
81
+ def normalize_llm_content(raw: Any) -> str:
82
+ """Aplati une réponse LLM (``str`` ou liste de blocs) en chaîne."""
83
+ if raw is None:
84
+ return ""
85
+ if isinstance(raw, str):
86
+ return raw
87
+ if isinstance(raw, list):
88
+ parts: list[str] = []
89
+ for chunk in raw:
90
+ if isinstance(chunk, str):
91
+ parts.append(chunk)
92
+ elif isinstance(chunk, dict) and isinstance(chunk.get("text"), str):
93
+ parts.append(chunk["text"])
94
+ else:
95
+ text = getattr(chunk, "text", None)
96
+ if isinstance(text, str):
97
+ parts.append(text)
98
+ return "".join(parts)
99
+ return str(raw)
100
+
101
+
102
+ def validate_llm_label(label: str, adapter: str) -> str:
103
+ """Valide un label d'adapter LLM (alphanum + ``_ -``) et le renvoie.
104
+
105
+ Le label compose le ``name`` du module (``<kind>:<label>``) ; le restreindre
106
+ garde les noms de modules sûrs et stables (provenance ``RunManifest``).
107
+ """
108
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
109
+ raise AdapterStepError(
110
+ f"{adapter} : label invalide {label!r} (alphanum + _ -)."
111
+ )
112
+ return label
113
+
114
+
115
+ def validate_role(role: str, adapter: str, supported: frozenset[str]) -> PipelineMode:
116
+ """Valide un mode d'adapter contre ceux que le fournisseur supporte."""
117
+ if role not in supported:
118
+ raise AdapterStepError(
119
+ f"{adapter} : mode {role!r} non supporté "
120
+ f"(modes : {', '.join(sorted(supported))})."
121
+ )
122
+ return cast(PipelineMode, role)
123
+
124
+
125
+ def llm_input_types(role: PipelineMode) -> frozenset[ArtifactType]:
126
+ """Types d'entrée requis par le ``role`` (mode du pipeline)."""
127
+ if role == "text_only":
128
+ return frozenset({ArtifactType.RAW_TEXT})
129
+ if role == "text_and_image":
130
+ return frozenset({ArtifactType.RAW_TEXT, ArtifactType.IMAGE})
131
+ return frozenset({ArtifactType.IMAGE}) # zero_shot
132
+
133
+
134
+ def llm_output_type(role: PipelineMode) -> ArtifactType:
135
+ """Type produit : ``RAW_TEXT`` en transcription, sinon ``CORRECTED_TEXT``."""
136
+ return (
137
+ ArtifactType.RAW_TEXT
138
+ if role == "zero_shot"
139
+ else ArtifactType.CORRECTED_TEXT
140
+ )
141
+
142
+
143
+ def default_prompt_for_role(role: PipelineMode) -> str:
144
+ """Prompt par défaut adapté au mode (transcription vs correction)."""
145
+ return (
146
+ DEFAULT_TRANSCRIPTION_PROMPT
147
+ if role == "zero_shot"
148
+ else DEFAULT_CORRECTION_PROMPT
149
+ )
150
+
151
+
152
+ def build_prompt(template: str, ocr_text: str) -> str:
153
+ return template.replace("{ocr_text}", ocr_text)
154
+
155
+
156
+ def load_ocr_text(inputs: dict[ArtifactType, Artifact], adapter_name: str) -> str:
157
+ artifact = inputs.get(ArtifactType.RAW_TEXT)
158
+ if artifact is None or artifact.uri is None:
159
+ raise AdapterStepError(
160
+ f"{adapter_name} : input RAW_TEXT manquant ou sans URI."
161
+ )
162
+ return read_plaintext(Path(artifact.uri).read_bytes())
163
+
164
+
165
+ def load_image_b64(
166
+ inputs: dict[ArtifactType, Artifact], adapter_name: str
167
+ ) -> tuple[str, str]:
168
+ """Charge l'``IMAGE`` d'entrée → ``(media_type, base64)`` pour un VLM."""
169
+ artifact = inputs.get(ArtifactType.IMAGE)
170
+ if artifact is None or artifact.uri is None:
171
+ raise AdapterStepError(
172
+ f"{adapter_name} : input IMAGE manquant ou sans URI."
173
+ )
174
+ path = Path(artifact.uri)
175
+ media_type = _MEDIA_TYPES.get(path.suffix.lower(), "image/png")
176
+ return media_type, b64encode(path.read_bytes()).decode("ascii")
177
+
178
+
179
+ def write_text_artifact(
180
+ workspace_uri: str,
181
+ document_id: str,
182
+ label: str,
183
+ adapter_name: str,
184
+ text: str,
185
+ *,
186
+ output_type: ArtifactType,
187
+ ) -> dict[ArtifactType, Artifact]:
188
+ """Écrit ``text`` dans le workspace → artefact du type produit par le mode."""
189
+ is_corrected = output_type == ArtifactType.CORRECTED_TEXT
190
+ suffix = "corrected.txt" if is_corrected else "raw.txt"
191
+ output_path = workspace_artifact_path(
192
+ workspace_uri, document_id, label, suffix
193
+ )
194
+ output_path.write_text(text, encoding="utf-8")
195
+ return {
196
+ output_type: Artifact(
197
+ id=f"{document_id}:{adapter_name}:{output_type.value}",
198
+ document_id=document_id,
199
+ type=output_type,
200
+ uri=str(output_path),
201
+ content_hash=compute_content_hash(text.encode("utf-8")),
202
+ )
203
+ }
204
+
205
+
206
+ def run_llm_step(
207
+ *,
208
+ role: PipelineMode,
209
+ label: str,
210
+ name: str,
211
+ prompt: str,
212
+ inputs: dict[ArtifactType, Artifact],
213
+ context: RunContext,
214
+ control: RunControl,
215
+ text_invoke: TextInvoke,
216
+ vision_invoke: VisionInvoke | None,
217
+ ) -> StepOutput:
218
+ """Exécute une étape LLM/VLM selon le ``role`` — logique de mode unique.
219
+
220
+ Aiguille sur le mode : charge les bonnes entrées (texte et/ou image), appelle
221
+ l'invocation fournisseur idoine, écrit l'artefact du bon type. ``zero_shot``
222
+ et ``text_and_image`` exigent ``vision_invoke`` (un fournisseur texte-seul,
223
+ ex. ollama, passe ``None`` → ces modes sont refusés proprement). Les jetons
224
+ exposés par le fournisseur remontent dans ``StepOutput.usage``.
225
+ """
226
+ control.raise_if_cancelled()
227
+ if context.workspace_uri is None:
228
+ raise AdapterStepError(
229
+ f"{name} : workspace requis (RunContext.workspace_uri)."
230
+ )
231
+ if role == "zero_shot":
232
+ if vision_invoke is None:
233
+ raise AdapterStepError(f"{name} : mode zero_shot requiert un VLM (vision).")
234
+ media_type, image_b64 = load_image_b64(inputs, name)
235
+ completion = vision_invoke(prompt, media_type, image_b64)
236
+ elif role == "text_and_image":
237
+ if vision_invoke is None:
238
+ raise AdapterStepError(
239
+ f"{name} : mode text_and_image requiert un VLM (vision)."
240
+ )
241
+ ocr_text = load_ocr_text(inputs, name)
242
+ media_type, image_b64 = load_image_b64(inputs, name)
243
+ completion = vision_invoke(
244
+ build_prompt(prompt, ocr_text), media_type, image_b64
245
+ )
246
+ else: # text_only
247
+ ocr_text = load_ocr_text(inputs, name)
248
+ completion = text_invoke(build_prompt(prompt, ocr_text))
249
+ artifacts = write_text_artifact(
250
+ context.workspace_uri,
251
+ context.document_id,
252
+ label,
253
+ name,
254
+ completion.text,
255
+ output_type=llm_output_type(role),
256
+ )
257
+ usage = (
258
+ ResourceUsage(
259
+ tokens_in=completion.tokens_in, tokens_out=completion.tokens_out
260
+ )
261
+ if completion.tokens_in is not None or completion.tokens_out is not None
262
+ else None
263
+ )
264
+ return StepOutput(artifacts=artifacts, usage=usage)
265
+
266
+
267
+ __all__ = [
268
+ "DEFAULT_CORRECTION_PROMPT",
269
+ "DEFAULT_TRANSCRIPTION_PROMPT",
270
+ "LLMCompletion",
271
+ "TextInvoke",
272
+ "VisionInvoke",
273
+ "build_prompt",
274
+ "default_prompt_for_role",
275
+ "llm_input_types",
276
+ "llm_output_type",
277
+ "load_image_b64",
278
+ "load_ocr_text",
279
+ "normalize_llm_content",
280
+ "run_llm_step",
281
+ "usage_tokens",
282
+ "validate_llm_label",
283
+ "validate_role",
284
+ "write_text_artifact",
285
+ ]
cinoc/adapters/llm/anthropic.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``AnthropicAdapter`` — post-correction LLM **et** transcription VLM (Claude).
2
+
3
+ Implémente le ``Module`` Protocol, trois modes (``PipelineMode``) selon le rôle :
4
+ ``text_only`` (texte → corrigé), ``text_and_image`` (Claude vision : image +
5
+ texte → corrigé), ``zero_shot`` (Claude vision : image → texte). Clé
6
+ ``ANTHROPIC_API_KEY`` ; SDK ``anthropic`` = **extra** importé paresseusement dans
7
+ ``_invoke_anthropic`` / ``_invoke_anthropic_vision`` (isolés → **mockables**, CI
8
+ sans clé ni réseau). La ``Deadline`` borne l'appel.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, ClassVar
14
+
15
+ from cinoc.adapters._resilience import call_resilient
16
+ from cinoc.adapters.llm._base import (
17
+ LLMCompletion,
18
+ default_prompt_for_role,
19
+ llm_input_types,
20
+ llm_output_type,
21
+ normalize_llm_content,
22
+ run_llm_step,
23
+ usage_tokens,
24
+ validate_llm_label,
25
+ validate_role,
26
+ )
27
+ from cinoc.domain.artifacts import Artifact, ArtifactType
28
+ from cinoc.domain.deadline import Deadline
29
+ from cinoc.domain.errors import AdapterStepError
30
+ from cinoc.domain.pipeline import PipelineMode
31
+ from cinoc.pipeline.protocols import ParamValue
32
+ from cinoc.pipeline.run_control import RunControl
33
+ from cinoc.pipeline.types import RunContext, StepOutput
34
+
35
+ _VERSION = "1.0"
36
+ _DEFAULT_MODEL = "claude-haiku-4-5-20251001"
37
+ _MAX_TOKENS = 4096
38
+ _SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
39
+
40
+
41
+ def _completion_from_message(response: Any) -> LLMCompletion:
42
+ """Réponse SDK ``messages.create`` → ``LLMCompletion`` (texte + jetons).
43
+
44
+ **Pur** (aucun réseau, aucun SDK) → la cartographie usage→jetons
45
+ (``input_tokens``/``output_tokens``, **noms propres à Claude**) est **testable
46
+ sur cassette** (étape 2d) ; le client live (``# pragma: no cover``) ne le
47
+ permettrait pas. Jetons → économie (coût cloud réel).
48
+ """
49
+ usage = getattr(response, "usage", None)
50
+ return LLMCompletion(
51
+ normalize_llm_content(getattr(response, "content", None)),
52
+ usage_tokens(getattr(usage, "input_tokens", None)),
53
+ usage_tokens(getattr(usage, "output_tokens", None)),
54
+ )
55
+
56
+
57
+ def _anthropic_client( # pragma: no cover -- réseau + clé API (cf. 'live')
58
+ model: str, content: object, deadline: Deadline
59
+ ) -> LLMCompletion:
60
+ """Appel ``messages.create`` partagé (texte ou multimodal)."""
61
+ import os
62
+
63
+ api_key = os.environ.get("ANTHROPIC_API_KEY")
64
+ if not api_key:
65
+ raise AdapterStepError("anthropic : ANTHROPIC_API_KEY manquante.")
66
+ try:
67
+ from anthropic import ( # type: ignore[import-not-found]
68
+ Anthropic,
69
+ AnthropicError,
70
+ )
71
+ except ImportError as exc:
72
+ raise AdapterStepError(
73
+ "anthropic : SDK non installé (pip install 'cinoc[anthropic]')."
74
+ ) from exc
75
+ client_kwargs: dict[str, object] = {"api_key": api_key}
76
+ timeout = deadline.as_sdk_timeout()
77
+ if timeout is not None:
78
+ client_kwargs["timeout"] = timeout
79
+ try:
80
+ # SDK anthropic typé strictement ; dicts valides à l'exécution (tests live).
81
+ client = Anthropic(**client_kwargs) # type: ignore[arg-type]
82
+
83
+ def _create() -> Any:
84
+ return client.messages.create(
85
+ model=model,
86
+ max_tokens=_MAX_TOKENS,
87
+ messages=[{"role": "user", "content": content}], # type: ignore[typeddict-item]
88
+ )
89
+
90
+ # Cap de concurrence (≤ N appels Anthropic simultanés) + retry borné sur
91
+ # 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
92
+ response = call_resilient("anthropic", _create)
93
+ except AnthropicError as exc:
94
+ raise AdapterStepError(
95
+ f"anthropic a échoué ({model}) : {type(exc).__name__}: {exc}"
96
+ ) from exc
97
+ return _completion_from_message(response)
98
+
99
+
100
+ def _invoke_anthropic( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
101
+ *, model: str, prompt: str, deadline: Deadline
102
+ ) -> LLMCompletion:
103
+ return _anthropic_client(model, prompt, deadline)
104
+
105
+
106
+ def _invoke_anthropic_vision( # pragma: no cover -- réseau + clé API (cf. 'live')
107
+ *, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
108
+ ) -> LLMCompletion:
109
+ content = [
110
+ {"type": "text", "text": prompt},
111
+ {
112
+ "type": "image",
113
+ "source": {
114
+ "type": "base64",
115
+ "media_type": media_type,
116
+ "data": image_b64,
117
+ },
118
+ },
119
+ ]
120
+ return _anthropic_client(model, content, deadline)
121
+
122
+
123
+ class AnthropicAdapter:
124
+ """Adapter Anthropic multi-mode (post-correction texte/image, transcription)."""
125
+
126
+ #: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
127
+ #: code qui les rend possibles : la capacité vision est une closure passée à
128
+ #: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
129
+ #: dérive — c'est arrivé (D-233).
130
+ SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
131
+ {"text_only", "text_and_image", "zero_shot"}
132
+ )
133
+
134
+
135
+ def __init__(
136
+ self,
137
+ *,
138
+ label: str,
139
+ model: str = _DEFAULT_MODEL,
140
+ role: str = "text_only",
141
+ prompt: str | None = None,
142
+ ) -> None:
143
+ self._label = validate_llm_label(label, "AnthropicAdapter")
144
+ self._model = model
145
+ self._role: PipelineMode = validate_role(role, "AnthropicAdapter", _SUPPORTED)
146
+ self._prompt = (
147
+ prompt if prompt is not None else default_prompt_for_role(self._role)
148
+ )
149
+
150
+ @property
151
+ def name(self) -> str:
152
+ return f"anthropic:{self._label}"
153
+
154
+ @property
155
+ def version(self) -> str:
156
+ return _VERSION
157
+
158
+ @property
159
+ def input_types(self) -> frozenset[ArtifactType]:
160
+ return llm_input_types(self._role)
161
+
162
+ @property
163
+ def output_types(self) -> frozenset[ArtifactType]:
164
+ return frozenset({llm_output_type(self._role)})
165
+
166
+ def execute(
167
+ self,
168
+ inputs: dict[ArtifactType, Artifact],
169
+ params: dict[str, ParamValue],
170
+ context: RunContext,
171
+ control: RunControl,
172
+ ) -> StepOutput:
173
+ def text_invoke(prompt: str) -> LLMCompletion:
174
+ return _invoke_anthropic(
175
+ model=self._model, prompt=prompt, deadline=context.deadline
176
+ )
177
+
178
+ def vision_invoke(
179
+ prompt: str, media_type: str, image_b64: str
180
+ ) -> LLMCompletion:
181
+ return _invoke_anthropic_vision(
182
+ model=self._model,
183
+ prompt=prompt,
184
+ media_type=media_type,
185
+ image_b64=image_b64,
186
+ deadline=context.deadline,
187
+ )
188
+
189
+ return run_llm_step(
190
+ role=self._role,
191
+ label=self._label,
192
+ name=self.name,
193
+ prompt=self._prompt,
194
+ inputs=inputs,
195
+ context=context,
196
+ control=control,
197
+ text_invoke=text_invoke,
198
+ vision_invoke=vision_invoke,
199
+ )
200
+
201
+
202
+ __all__ = ["AnthropicAdapter"]
cinoc/adapters/llm/mistral.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``MistralAdapter`` — post-correction LLM **et** transcription VLM (API Mistral).
2
+
3
+ Implémente le ``Module`` Protocol, trois modes (``PipelineMode``) selon le rôle :
4
+ ``text_only`` (texte → corrigé), ``text_and_image`` (Pixtral : image + texte →
5
+ corrigé), ``zero_shot`` (Pixtral : image → texte). Clé ``MISTRAL_API_KEY`` ; SDK
6
+ ``mistralai`` = **extra** importé paresseusement dans ``_invoke_mistral`` /
7
+ ``_invoke_mistral_vision`` (isolés → **mockables**, CI sans clé ni réseau).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any, ClassVar
14
+
15
+ from cinoc.adapters._resilience import call_resilient
16
+ from cinoc.adapters.llm._base import (
17
+ LLMCompletion,
18
+ default_prompt_for_role,
19
+ llm_input_types,
20
+ llm_output_type,
21
+ normalize_llm_content,
22
+ run_llm_step,
23
+ usage_tokens,
24
+ validate_llm_label,
25
+ validate_role,
26
+ )
27
+ from cinoc.domain.artifacts import Artifact, ArtifactType
28
+ from cinoc.domain.deadline import Deadline
29
+ from cinoc.domain.errors import AdapterStepError
30
+ from cinoc.domain.pipeline import PipelineMode
31
+ from cinoc.pipeline.protocols import ParamValue
32
+ from cinoc.pipeline.run_control import RunControl
33
+ from cinoc.pipeline.types import RunContext, StepOutput
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _VERSION = "1.0"
38
+ _DEFAULT_MODEL = "mistral-small-latest"
39
+ _SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
40
+
41
+
42
+ def _completion_from_chat(response: Any) -> LLMCompletion:
43
+ """Réponse SDK ``chat.complete`` → ``LLMCompletion`` (texte + jetons).
44
+
45
+ **Pur** (aucun réseau, aucun SDK) → la cartographie usage→jetons
46
+ (``prompt_tokens``/``completion_tokens``) est **testable sur cassette** (étape
47
+ 2d) ; le client live (``# pragma: no cover``) ne le permettrait pas. Jetons →
48
+ économie (coût cloud réel).
49
+ """
50
+ usage = getattr(response, "usage", None)
51
+ tokens_in = usage_tokens(getattr(usage, "prompt_tokens", None))
52
+ tokens_out = usage_tokens(getattr(usage, "completion_tokens", None))
53
+ choices = getattr(response, "choices", None)
54
+ if not choices:
55
+ return LLMCompletion("", tokens_in, tokens_out)
56
+ return LLMCompletion(
57
+ normalize_llm_content(choices[0].message.content), tokens_in, tokens_out
58
+ )
59
+
60
+
61
+ def _mistral_client( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
62
+ model: str, content: object, deadline: Deadline
63
+ ) -> LLMCompletion:
64
+ """Appel ``chat.complete`` partagé (texte ou multimodal)."""
65
+ import os
66
+
67
+ api_key = os.environ.get("MISTRAL_API_KEY")
68
+ if not api_key:
69
+ raise AdapterStepError("mistral : MISTRAL_API_KEY manquante.")
70
+ try:
71
+ from mistralai import Mistral # type: ignore[import-not-found]
72
+ except ImportError as exc:
73
+ raise AdapterStepError(
74
+ "mistral : SDK non installé (pip install 'cinoc[mistral]')."
75
+ ) from exc
76
+ client_kwargs: dict[str, object] = {"api_key": api_key}
77
+ timeout = deadline.as_sdk_timeout()
78
+ if timeout is not None:
79
+ client_kwargs["timeout_ms"] = int(timeout * 1000)
80
+ try:
81
+ # Le SDK mistralai 1.x type strictement ses kwargs/messages ; on lui
82
+ # passe des dicts valides à l'exécution (cf. tests live). ``ignore`` ciblé.
83
+ client = Mistral(**client_kwargs) # type: ignore[arg-type]
84
+
85
+ def _complete() -> Any:
86
+ return client.chat.complete(
87
+ model=model,
88
+ messages=[{"role": "user", "content": content}], # type: ignore[arg-type]
89
+ temperature=0.0,
90
+ )
91
+
92
+ # Cap de concurrence (≤ N appels Mistral simultanés) + retry borné sur
93
+ # 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
94
+ response = call_resilient("mistral", _complete)
95
+ except Exception as exc: # le SDK lève des erreurs HTTP/SDK variées
96
+ raise AdapterStepError(
97
+ f"mistral a échoué ({model}) : {type(exc).__name__}: {exc}"
98
+ ) from exc
99
+ return _completion_from_chat(response)
100
+
101
+
102
+ def _invoke_mistral( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
103
+ *, model: str, prompt: str, deadline: Deadline
104
+ ) -> LLMCompletion:
105
+ return _mistral_client(model, prompt, deadline)
106
+
107
+
108
+ def _invoke_mistral_vision( # pragma: no cover -- réseau + clé API (cf. 'live')
109
+ *, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
110
+ ) -> LLMCompletion:
111
+ content = [
112
+ {"type": "text", "text": prompt},
113
+ {"type": "image_url", "image_url": f"data:{media_type};base64,{image_b64}"},
114
+ ]
115
+ return _mistral_client(model, content, deadline)
116
+
117
+
118
+ class MistralAdapter:
119
+ """Adapter Mistral multi-mode (post-correction texte/image, transcription VLM)."""
120
+
121
+ #: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
122
+ #: code qui les rend possibles : la capacité vision est une closure passée à
123
+ #: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
124
+ #: dérive — c'est arrivé (D-233).
125
+ SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
126
+ {"text_only", "text_and_image", "zero_shot"}
127
+ )
128
+
129
+
130
+ def __init__(
131
+ self,
132
+ *,
133
+ label: str,
134
+ model: str = _DEFAULT_MODEL,
135
+ role: str = "text_only",
136
+ prompt: str | None = None,
137
+ ) -> None:
138
+ self._label = validate_llm_label(label, "MistralAdapter")
139
+ self._model = model
140
+ self._role: PipelineMode = validate_role(role, "MistralAdapter", _SUPPORTED)
141
+ self._prompt = (
142
+ prompt if prompt is not None else default_prompt_for_role(self._role)
143
+ )
144
+
145
+ @property
146
+ def name(self) -> str:
147
+ return f"mistral:{self._label}"
148
+
149
+ @property
150
+ def version(self) -> str:
151
+ return _VERSION
152
+
153
+ @property
154
+ def input_types(self) -> frozenset[ArtifactType]:
155
+ return llm_input_types(self._role)
156
+
157
+ @property
158
+ def output_types(self) -> frozenset[ArtifactType]:
159
+ return frozenset({llm_output_type(self._role)})
160
+
161
+ def execute(
162
+ self,
163
+ inputs: dict[ArtifactType, Artifact],
164
+ params: dict[str, ParamValue],
165
+ context: RunContext,
166
+ control: RunControl,
167
+ ) -> StepOutput:
168
+ def text_invoke(prompt: str) -> LLMCompletion:
169
+ return _invoke_mistral(
170
+ model=self._model, prompt=prompt, deadline=context.deadline
171
+ )
172
+
173
+ def vision_invoke(
174
+ prompt: str, media_type: str, image_b64: str
175
+ ) -> LLMCompletion:
176
+ return _invoke_mistral_vision(
177
+ model=self._model,
178
+ prompt=prompt,
179
+ media_type=media_type,
180
+ image_b64=image_b64,
181
+ deadline=context.deadline,
182
+ )
183
+
184
+ return run_llm_step(
185
+ role=self._role,
186
+ label=self._label,
187
+ name=self.name,
188
+ prompt=self._prompt,
189
+ inputs=inputs,
190
+ context=context,
191
+ control=control,
192
+ text_invoke=text_invoke,
193
+ vision_invoke=vision_invoke,
194
+ )
195
+
196
+
197
+ def list_mistral_models(*, timeout: float = 5.0) -> tuple[str, ...]:
198
+ """IDs des modèles disponibles pour la clé Mistral courante (``models.list``).
199
+
200
+ **Best-effort, commodité d'UI** : ``MISTRAL_API_KEY`` absente, SDK absent ou
201
+ erreur réseau → ``()`` (l'interface retombe sur la saisie libre). Rien de
202
+ hardcodé : la liste vient **directement de l'API Mistral**.
203
+ """
204
+ import os
205
+
206
+ api_key = os.environ.get("MISTRAL_API_KEY")
207
+ if not api_key:
208
+ return ()
209
+ try:
210
+ from mistralai import Mistral # type: ignore[import-not-found]
211
+ except ImportError:
212
+ return ()
213
+ try:
214
+ client = Mistral(api_key=api_key, timeout_ms=int(timeout * 1000))
215
+ response = client.models.list()
216
+ except Exception as exc: # noqa: BLE001 — voir rationale ci-dessous
217
+ # Catch-all **assumé** : commodité d'UI qui ne doit jamais casser la
218
+ # page du composeur. Le SDK mistralai **enveloppe** ses erreurs HTTP
219
+ # (auth/429/5xx) dans ses propres types + propage des erreurs httpx pour
220
+ # le transport ; narrower au seul ``httpx.HTTPError`` (comme ollama, qui
221
+ # appelle httpx en direct) laisserait fuir une erreur SDK et casserait la
222
+ # page. On trace (§7) et on retombe sur la saisie libre.
223
+ logger.warning("[mistral] liste des modèles indisponible : %s", exc)
224
+ return ()
225
+ data = getattr(response, "data", None) or ()
226
+ ids = [m.id for m in data if isinstance(getattr(m, "id", None), str)]
227
+ return tuple(sorted(set(ids)))
228
+
229
+
230
+ __all__ = ["MistralAdapter", "list_mistral_models"]
cinoc/adapters/llm/ollama.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``OllamaAdapter`` — post-correction LLM via un serveur ollama local.
2
+
3
+ Deuxième famille LLM : prouve que le ``Module`` Protocol **généralise au-delà
4
+ d'openai** — même contrat ``RAW_TEXT`` → ``CORRECTED_TEXT``, sortie identique,
5
+ aucun cas particulier. Transport ``httpx`` (extra), importé paresseusement et
6
+ **isolé** dans ``_invoke_ollama`` (→ mockable, CI sans serveur).
7
+
8
+ C'est l'**implémentation de référence de l'annulation câblée** : ``_invoke_ollama``
9
+ enregistre ``client.close`` via ``RunControl.register_cancel_handle`` ; un
10
+ ``trigger_cancel`` ferme la connexion et **interrompt la requête en vol**. La
11
+ distinction « annulation vs vraie panne réseau » se fait en sondant
12
+ ``is_cancelled`` (``_fail_or_cancel``) — pas par une heuristique de message
13
+ fragile (dette de l'implémentation source, corrigée au portage).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import ClassVar, NoReturn
19
+
20
+ from cinoc.adapters.llm._base import (
21
+ LLMCompletion,
22
+ default_prompt_for_role,
23
+ llm_input_types,
24
+ llm_output_type,
25
+ normalize_llm_content,
26
+ run_llm_step,
27
+ usage_tokens,
28
+ validate_llm_label,
29
+ validate_role,
30
+ )
31
+ from cinoc.domain.artifacts import Artifact, ArtifactType
32
+ from cinoc.domain.deadline import Deadline
33
+ from cinoc.domain.errors import AdapterStepError
34
+ from cinoc.domain.pipeline import PipelineMode
35
+ from cinoc.pipeline.protocols import ParamValue
36
+ from cinoc.pipeline.run_control import RunControl
37
+ from cinoc.pipeline.types import RunContext, StepOutput
38
+
39
+ _VERSION = "1.0"
40
+ _DEFAULT_MODEL = "llama3"
41
+ #: Les trois modes, comme les fournisseurs distants. ``zero_shot`` a un sens
42
+ #: particulier ici : un modèle d'OCR local (churro, par exemple) transcrit
43
+ #: l'image sans OCR amont.
44
+ _SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
45
+
46
+ _DEFAULT_HOST = "http://localhost:11434"
47
+
48
+
49
+ def _fail_or_cancel(control: RunControl, model: str, exc: Exception) -> NoReturn:
50
+ """Traduit un échec d'appel ollama.
51
+
52
+ Si l'annulation a été déclenchée, le ``client.close`` enregistré a fait
53
+ échouer la requête en vol : on lève alors ``RunCancelledError`` (via
54
+ ``raise_if_cancelled``). Sinon c'est une vraie panne → ``AdapterStepError``.
55
+ Sonder ``is_cancelled`` est **fiable**, là où l'implémentation source
56
+ devinait par le message d'exception (heuristique fragile, D-A).
57
+ """
58
+ control.raise_if_cancelled()
59
+ raise AdapterStepError(f"ollama a échoué ({model}) : {exc}") from exc
60
+
61
+
62
+ def _invoke_ollama( # pragma: no cover -- réseau (serveur ollama ; cf. marqueur 'live')
63
+ *,
64
+ model: str,
65
+ prompt: str,
66
+ host: str,
67
+ deadline: Deadline,
68
+ control: RunControl,
69
+ image_b64: str | None = None,
70
+ ) -> LLMCompletion:
71
+ try:
72
+ import httpx # type: ignore[import-not-found]
73
+ except ImportError as exc:
74
+ raise AdapterStepError(
75
+ "ollama : httpx non installé (pip install 'cinoc[ollama]')."
76
+ ) from exc
77
+ # L'image voyage dans un tableau ``images`` **du message**, en base64 nu —
78
+ # pas d'URI ``data:``, pas de type de média : ollama ne suit pas la
79
+ # convention d'OpenAI ici, et un ``data:`` passé tel quel est ignoré en
80
+ # silence (le modèle répond alors sans avoir rien vu).
81
+ demande: dict[str, object] = {"role": "user", "content": prompt}
82
+ if image_b64 is not None:
83
+ demande["images"] = [image_b64]
84
+ payload = {"model": model, "messages": [demande], "stream": False}
85
+ client = httpx.Client(timeout=deadline.as_sdk_timeout())
86
+ # Annulation câblée : fermer le client interrompt la requête en vol.
87
+ control.register_cancel_handle(client.close)
88
+ try:
89
+ # **/api/chat** (et non /api/generate) : applique le gabarit de chat du
90
+ # modèle. Indispensable pour les modèles *instruct* (ex. churro), qui
91
+ # échouent sur /api/generate (complétion brute). C'est l'endpoint
92
+ # qu'utilise ``ollama run``.
93
+ response = client.post(f"{host}/api/chat", json=payload)
94
+ response.raise_for_status()
95
+ data = response.json()
96
+ except httpx.HTTPError as exc:
97
+ _fail_or_cancel(control, model, exc)
98
+ finally:
99
+ client.close()
100
+ # Ollama peut renvoyer une **erreur en HTTP 200** (modèle introuvable sous ce
101
+ # nom, mémoire insuffisante…) : ``raise_for_status`` ne la voit pas. On la
102
+ # remonte explicitement, sinon la « correction » serait un texte vide silencieux.
103
+ if isinstance(data, dict) and data.get("error"):
104
+ raise AdapterStepError(f"ollama a échoué ({model}) : {data['error']}")
105
+ message = data.get("message") if isinstance(data, dict) else None
106
+ content = message.get("content") if isinstance(message, dict) else None
107
+ # /api/chat expose la consommation : prompt_eval_count / eval_count.
108
+ counts = data if isinstance(data, dict) else {}
109
+ return LLMCompletion(
110
+ normalize_llm_content(content),
111
+ usage_tokens(counts.get("prompt_eval_count")),
112
+ usage_tokens(counts.get("eval_count")),
113
+ )
114
+
115
+
116
+ def list_installed_models(
117
+ host: str = _DEFAULT_HOST, *, timeout: float = 3.0
118
+ ) -> tuple[str, ...]:
119
+ """Noms des modèles installés sur le serveur ollama (``GET /api/tags``).
120
+
121
+ **Best-effort, commodité d'UI** : serveur injoignable, ``httpx`` absent ou
122
+ réponse inattendue → ``()`` (l'interface retombe alors sur la saisie libre).
123
+ Aucune exception propagée. Le ``name`` renvoyé est le **tag exact** servable à
124
+ ``/api/generate`` (ex. ``gemma3:1b`` ou ``hf.co/…/churro-3B-GGUF:Q4_K_M``).
125
+ """
126
+ try:
127
+ import httpx # type: ignore[import-not-found]
128
+ except ImportError:
129
+ return ()
130
+ try:
131
+ with httpx.Client(timeout=timeout) as client:
132
+ response = client.get(f"{host}/api/tags")
133
+ response.raise_for_status()
134
+ data = response.json()
135
+ except (httpx.HTTPError, ValueError):
136
+ return ()
137
+ models = data.get("models") if isinstance(data, dict) else None
138
+ if not isinstance(models, list):
139
+ return ()
140
+ return tuple(
141
+ m["name"]
142
+ for m in models
143
+ if isinstance(m, dict) and isinstance(m.get("name"), str)
144
+ )
145
+
146
+
147
+ class OllamaAdapter:
148
+ """LLM/VLM local (ollama), trois modes comme les fournisseurs distants.
149
+
150
+ L'adapter était ``text_only`` : un modèle vision installé en local ne
151
+ pouvait pas concourir au banc, alors que des poids figés sont **plus**
152
+ reproductibles qu'un instantané d'API susceptible d'être déprécié.
153
+ """
154
+
155
+ #: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
156
+ #: code qui les rend possibles : la capacité vision est une closure passée à
157
+ #: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
158
+ #: dérive — c'est arrivé (D-233).
159
+ SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
160
+ {"text_only", "text_and_image", "zero_shot"}
161
+ )
162
+
163
+
164
+ def __init__(
165
+ self,
166
+ *,
167
+ label: str,
168
+ model: str = _DEFAULT_MODEL,
169
+ host: str = _DEFAULT_HOST,
170
+ role: str = "text_only",
171
+ prompt: str | None = None,
172
+ ) -> None:
173
+ self._label = validate_llm_label(label, "OllamaAdapter")
174
+ self._model = model
175
+ self._host = host
176
+ self._role: PipelineMode = validate_role(role, "OllamaAdapter", _SUPPORTED)
177
+ self._prompt = (
178
+ prompt if prompt is not None else default_prompt_for_role(self._role)
179
+ )
180
+
181
+ @property
182
+ def name(self) -> str:
183
+ return f"ollama:{self._label}"
184
+
185
+ @property
186
+ def version(self) -> str:
187
+ return _VERSION
188
+
189
+ @property
190
+ def input_types(self) -> frozenset[ArtifactType]:
191
+ return llm_input_types(self._role)
192
+
193
+ @property
194
+ def output_types(self) -> frozenset[ArtifactType]:
195
+ return frozenset({llm_output_type(self._role)})
196
+
197
+ def execute(
198
+ self,
199
+ inputs: dict[ArtifactType, Artifact],
200
+ params: dict[str, ParamValue],
201
+ context: RunContext,
202
+ control: RunControl,
203
+ ) -> StepOutput:
204
+ def text_invoke(prompt: str) -> LLMCompletion:
205
+ return _invoke_ollama(
206
+ model=self._model,
207
+ prompt=prompt,
208
+ host=self._host,
209
+ deadline=context.deadline,
210
+ control=control,
211
+ )
212
+
213
+ def vision_invoke(
214
+ prompt: str, media_type: str, image_b64: str
215
+ ) -> LLMCompletion:
216
+ # ``media_type`` est ignoré : ollama devine le format des octets.
217
+ del media_type
218
+ return _invoke_ollama(
219
+ model=self._model,
220
+ prompt=prompt,
221
+ host=self._host,
222
+ deadline=context.deadline,
223
+ control=control,
224
+ image_b64=image_b64,
225
+ )
226
+
227
+ return run_llm_step(
228
+ role=self._role,
229
+ label=self._label,
230
+ name=self.name,
231
+ prompt=self._prompt,
232
+ inputs=inputs,
233
+ context=context,
234
+ control=control,
235
+ text_invoke=text_invoke,
236
+ vision_invoke=vision_invoke,
237
+ )
238
+
239
+
240
+ __all__ = ["OllamaAdapter"]
cinoc/adapters/llm/ollama_structured.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``OllamaStructuredClient`` — sortie JSON contrainte, serveur local.
2
+
3
+ ``saknussemm`` ne consomme qu'une seule capacité d'un modèle
4
+ (``StructuredCompletionClient``) : rendre le JSON décrit par un schéma. Ce
5
+ client la fournit depuis un ``ollama`` local — aucune clé d'API, poids figés,
6
+ donc plus reproductible qu'un instantané d'API qui peut être déprécié.
7
+
8
+ Deux détails qui ne sont pas des détails :
9
+
10
+ * ``num_ctx`` est **explicite**. Le défaut d'``ollama`` est 2048 jetons et la
11
+ troncature est **silencieuse** : le JSON revient coupé au milieu d'une chaîne,
12
+ et l'erreur ressemble à une hallucination du modèle alors que c'est le serveur
13
+ qui a coupé.
14
+ * les échecs de transport sont traduits en ``ProviderTransientError`` /
15
+ ``ProviderPermanentError``. La recouvrabilité de ``saknussemm`` est une liste
16
+ blanche : une exception brute est traitée comme un bug et **fait échouer** le
17
+ run au lieu d'être réessayée en boucle.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from typing import Any
24
+
25
+ _DEFAULT_HOST = "http://localhost:11434"
26
+ #: Le défaut d'ollama (2048) tronque en silence une page de journal.
27
+ _DEFAULT_NUM_CTX = 8192
28
+
29
+
30
+ class OllamaStructuredClient:
31
+ """``complete_structured`` sur ``/api/chat``, schéma JSON contraint."""
32
+
33
+ def __init__(
34
+ self,
35
+ *,
36
+ host: str = _DEFAULT_HOST,
37
+ num_ctx: int = _DEFAULT_NUM_CTX,
38
+ timeout: float = 300.0,
39
+ ) -> None:
40
+ self._host = host.rstrip("/")
41
+ self._num_ctx = num_ctx
42
+ self._timeout = timeout
43
+
44
+ async def complete_structured(
45
+ self,
46
+ api_key: str, # noqa: ARG002 — un serveur local n'en a pas
47
+ model: str,
48
+ system_prompt: str,
49
+ user_payload: dict[str, Any],
50
+ json_schema: dict[str, Any],
51
+ temperature: float = 0.0,
52
+ ) -> tuple[dict[str, Any], Any]:
53
+ import httpx # noqa: PLC0415
54
+
55
+ # Les classes concrètes vivent en ``core.protocols``, à côté du contrat
56
+ # qui les lève — ``errors`` n'ancre que leur base ``ProviderError``.
57
+ from saknussemm.core.protocols import ( # type: ignore[import-not-found] # noqa: PLC0415
58
+ ProviderPermanentError,
59
+ ProviderTransientError,
60
+ )
61
+
62
+ body = {
63
+ "model": model,
64
+ "messages": [
65
+ {"role": "system", "content": system_prompt},
66
+ {
67
+ "role": "user",
68
+ "content": json.dumps(user_payload, ensure_ascii=False),
69
+ },
70
+ ],
71
+ "stream": False,
72
+ "format": _bare_schema(json_schema),
73
+ "options": {"temperature": temperature, "num_ctx": self._num_ctx},
74
+ }
75
+ try:
76
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
77
+ response = await client.post(f"{self._host}/api/chat", json=body)
78
+ response.raise_for_status()
79
+ data = response.json()
80
+ except httpx.HTTPStatusError as exc:
81
+ status = exc.response.status_code
82
+ message = f"ollama a répondu {status} ({model})"
83
+ # 4xx = la requête est fautive, la réessayer la referait échouer.
84
+ if 400 <= status < 500:
85
+ raise ProviderPermanentError(message) from exc
86
+ raise ProviderTransientError(message) from exc
87
+ except (httpx.TimeoutException, httpx.TransportError) as exc:
88
+ raise ProviderTransientError(
89
+ f"ollama injoignable ({model}) : {exc}"
90
+ ) from exc
91
+
92
+ content = (data.get("message") or {}).get("content") or ""
93
+ try:
94
+ parsed = json.loads(content)
95
+ except json.JSONDecodeError as exc:
96
+ raise ProviderTransientError(
97
+ f"ollama ({model}) n'a pas rendu du JSON — réponse tronquée ou "
98
+ f"hors schéma : {content[:200]!r}"
99
+ ) from exc
100
+ if not isinstance(parsed, dict):
101
+ raise ProviderTransientError(
102
+ f"ollama ({model}) a rendu {type(parsed).__name__}, pas un objet."
103
+ )
104
+ return parsed, None
105
+
106
+
107
+ def _bare_schema(json_schema: dict[str, Any]) -> dict[str, Any]:
108
+ """``format`` d'ollama attend le schéma **nu**, pas l'enveloppe OpenAI."""
109
+ direct = json_schema.get("schema")
110
+ if isinstance(direct, dict):
111
+ return direct
112
+ inner = json_schema.get("json_schema")
113
+ if isinstance(inner, dict):
114
+ nested = inner.get("schema")
115
+ if isinstance(nested, dict):
116
+ return nested
117
+ return json_schema
118
+
119
+
120
+ __all__ = ["OllamaStructuredClient"]
cinoc/adapters/llm/openai.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``OpenAIAdapter`` — post-correction LLM **et** transcription VLM (API OpenAI).
2
+
3
+ Implémente le ``Module`` Protocol. Trois modes (``PipelineMode``) selon le rôle
4
+ passé à la construction : ``text_only`` (``RAW_TEXT`` → ``CORRECTED_TEXT``),
5
+ ``text_and_image`` (image + texte → corrigé), ``zero_shot`` (image → texte). La
6
+ clé vient de ``OPENAI_API_KEY`` ; le SDK ``openai`` est un **extra** importé
7
+ paresseusement dans ``_invoke_openai`` / ``_invoke_openai_vision`` (isolés →
8
+ **mockables**, CI sans clé ni réseau). La ``Deadline`` borne l'appel.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, ClassVar
14
+
15
+ from cinoc.adapters._resilience import call_resilient
16
+ from cinoc.adapters.llm._base import (
17
+ LLMCompletion,
18
+ default_prompt_for_role,
19
+ llm_input_types,
20
+ llm_output_type,
21
+ normalize_llm_content,
22
+ run_llm_step,
23
+ usage_tokens,
24
+ validate_llm_label,
25
+ validate_role,
26
+ )
27
+ from cinoc.domain.artifacts import Artifact, ArtifactType
28
+ from cinoc.domain.deadline import Deadline
29
+ from cinoc.domain.errors import AdapterStepError
30
+ from cinoc.domain.pipeline import PipelineMode
31
+ from cinoc.pipeline.protocols import ParamValue
32
+ from cinoc.pipeline.run_control import RunControl
33
+ from cinoc.pipeline.types import RunContext, StepOutput
34
+
35
+ _VERSION = "1.0"
36
+ _DEFAULT_MODEL = "gpt-4o-mini"
37
+ _SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
38
+
39
+
40
+ def _completion_from_chat(response: Any) -> LLMCompletion:
41
+ """Réponse SDK ``chat.completions`` → ``LLMCompletion`` (texte + jetons).
42
+
43
+ **Pur** (aucun réseau, aucun import SDK) → la cartographie usage→jetons
44
+ (``prompt_tokens``/``completion_tokens``) est **testable sur une réponse de
45
+ cassette**, ce que le client live (``# pragma: no cover``) ne permet pas. Les
46
+ jetons alimentent l'économie (coût réel par modèle).
47
+ """
48
+ usage = getattr(response, "usage", None)
49
+ tokens_in = usage_tokens(getattr(usage, "prompt_tokens", None))
50
+ tokens_out = usage_tokens(getattr(usage, "completion_tokens", None))
51
+ choices = getattr(response, "choices", None)
52
+ if not choices:
53
+ return LLMCompletion("", tokens_in, tokens_out)
54
+ return LLMCompletion(
55
+ normalize_llm_content(choices[0].message.content), tokens_in, tokens_out
56
+ )
57
+
58
+
59
+ def _openai_client( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
60
+ model: str, messages: list[dict[str, object]], deadline: Deadline
61
+ ) -> LLMCompletion:
62
+ """Appel ``chat.completions`` partagé (texte ou multimodal)."""
63
+ import os
64
+
65
+ api_key = os.environ.get("OPENAI_API_KEY")
66
+ if not api_key:
67
+ raise AdapterStepError("openai : OPENAI_API_KEY manquante.")
68
+ try:
69
+ from openai import OpenAI, OpenAIError # type: ignore[import-not-found]
70
+ except ImportError as exc:
71
+ raise AdapterStepError(
72
+ "openai : SDK non installé (pip install 'cinoc[openai]')."
73
+ ) from exc
74
+ kwargs: dict[str, object] = {
75
+ "model": model,
76
+ "messages": messages,
77
+ "temperature": 0.0,
78
+ }
79
+ timeout = deadline.as_sdk_timeout()
80
+ if timeout is not None:
81
+ kwargs["timeout"] = timeout
82
+ try:
83
+ # SDK openai typé strictement ; kwargs valides à l'exécution (tests live).
84
+ client = OpenAI(api_key=api_key)
85
+
86
+ def _create() -> Any:
87
+ return client.chat.completions.create(**kwargs) # type: ignore[call-overload]
88
+
89
+ # Cap de concurrence (≤ N appels OpenAI simultanés) + retry borné sur
90
+ # 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
91
+ response = call_resilient("openai", _create)
92
+ except OpenAIError as exc:
93
+ raise AdapterStepError(
94
+ f"openai a échoué ({model}) : {type(exc).__name__}: {exc}"
95
+ ) from exc
96
+ return _completion_from_chat(response)
97
+
98
+
99
+ def _invoke_openai( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
100
+ *, model: str, prompt: str, deadline: Deadline
101
+ ) -> LLMCompletion:
102
+ return _openai_client(model, [{"role": "user", "content": prompt}], deadline)
103
+
104
+
105
+ def _invoke_openai_vision( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
106
+ *, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
107
+ ) -> LLMCompletion:
108
+ content = [
109
+ {"type": "text", "text": prompt},
110
+ {
111
+ "type": "image_url",
112
+ "image_url": {"url": f"data:{media_type};base64,{image_b64}"},
113
+ },
114
+ ]
115
+ return _openai_client(model, [{"role": "user", "content": content}], deadline)
116
+
117
+
118
+ class OpenAIAdapter:
119
+ """Adapter OpenAI multi-mode (post-correction texte/image, transcription VLM)."""
120
+
121
+ #: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
122
+ #: code qui les rend possibles : la capacité vision est une closure passée à
123
+ #: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
124
+ #: dérive — c'est arrivé (D-233).
125
+ SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
126
+ {"text_only", "text_and_image", "zero_shot"}
127
+ )
128
+
129
+
130
+ def __init__(
131
+ self,
132
+ *,
133
+ label: str,
134
+ model: str = _DEFAULT_MODEL,
135
+ role: str = "text_only",
136
+ prompt: str | None = None,
137
+ ) -> None:
138
+ self._label = validate_llm_label(label, "OpenAIAdapter")
139
+ self._model = model
140
+ self._role: PipelineMode = validate_role(role, "OpenAIAdapter", _SUPPORTED)
141
+ self._prompt = (
142
+ prompt if prompt is not None else default_prompt_for_role(self._role)
143
+ )
144
+
145
+ @property
146
+ def name(self) -> str:
147
+ return f"openai:{self._label}"
148
+
149
+ @property
150
+ def version(self) -> str:
151
+ return _VERSION
152
+
153
+ @property
154
+ def input_types(self) -> frozenset[ArtifactType]:
155
+ return llm_input_types(self._role)
156
+
157
+ @property
158
+ def output_types(self) -> frozenset[ArtifactType]:
159
+ return frozenset({llm_output_type(self._role)})
160
+
161
+ def execute(
162
+ self,
163
+ inputs: dict[ArtifactType, Artifact],
164
+ params: dict[str, ParamValue],
165
+ context: RunContext,
166
+ control: RunControl,
167
+ ) -> StepOutput:
168
+ def text_invoke(prompt: str) -> LLMCompletion:
169
+ return _invoke_openai(
170
+ model=self._model, prompt=prompt, deadline=context.deadline
171
+ )
172
+
173
+ def vision_invoke(
174
+ prompt: str, media_type: str, image_b64: str
175
+ ) -> LLMCompletion:
176
+ return _invoke_openai_vision(
177
+ model=self._model,
178
+ prompt=prompt,
179
+ media_type=media_type,
180
+ image_b64=image_b64,
181
+ deadline=context.deadline,
182
+ )
183
+
184
+ return run_llm_step(
185
+ role=self._role,
186
+ label=self._label,
187
+ name=self.name,
188
+ prompt=self._prompt,
189
+ inputs=inputs,
190
+ context=context,
191
+ control=control,
192
+ text_invoke=text_invoke,
193
+ vision_invoke=vision_invoke,
194
+ )
195
+
196
+
197
+ __all__ = ["OpenAIAdapter"]
cinoc/adapters/ner/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Adapters NER : extraction d'entités nommées (couche 5)."""
cinoc/adapters/ner/spacy_extractor.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``SpacyNerExtractor`` — extraction d'entités nommées via spaCy (couche 5).
2
+
3
+ Brique de pipeline ``RAW_TEXT|CORRECTED_TEXT → ENTITIES`` (la NER est une étape
4
+ explicite, CLAUDE.md §3). Implémente le ``Module`` Protocol **directement**. Le
5
+ SDK ``spacy`` + le **modèle** sont un extra optionnel (``cinoc[ner]`` + ``python
6
+ -m spacy download <modèle>``), importés paresseusement.
7
+
8
+ **Anti-silence (réparation R14 amont, bug du modèle source).** Si spaCy ou le
9
+ modèle manque, l'adapter **lève** ``AdapterStepError`` avec le message
10
+ d'installation — il ne renvoie **jamais** ``[]`` (le défaut de la source : un
11
+ benchmark qui « réussit » avec un rappel 0 sans message). L'orchestrateur isole
12
+ alors ce (concurrent × document) ; le document reste simplement non scoré.
13
+
14
+ L'artefact ``ENTITIES`` embarque **le texte d'entrée** (``{"text", "entities"}``)
15
+ — c'est lui qui rend la reprojection R14 auto-suffisante côté évaluation.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ from typing import Any
23
+
24
+ from cinoc.adapters._workspace import workspace_artifact_path
25
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
26
+ from cinoc.domain.errors import AdapterStepError
27
+ from cinoc.pipeline.protocols import ParamValue
28
+ from cinoc.pipeline.run_control import RunControl
29
+ from cinoc.pipeline.types import RunContext, StepOutput
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ _VERSION = "1.0"
34
+
35
+ #: Normalisation des labels spaCy vers les conventions courtes HIPE/CoNLL.
36
+ _DEFAULT_LABEL_MAPPING: dict[str, str] = {
37
+ "PERSON": "PER",
38
+ "PER": "PER",
39
+ "LOC": "LOC",
40
+ "GPE": "LOC",
41
+ "ORG": "ORG",
42
+ "DATE": "DATE",
43
+ "TIME": "DATE",
44
+ "MISC": "MISC",
45
+ }
46
+
47
+ #: Texte d'entrée accepté : sortie OCR brute **ou** corrigée par un LLM.
48
+ _INPUT_TYPES = frozenset({ArtifactType.RAW_TEXT, ArtifactType.CORRECTED_TEXT})
49
+
50
+
51
+ class SpacyNerExtractor:
52
+ """NER spaCy : ``RAW_TEXT|CORRECTED_TEXT`` → ``ENTITIES`` (fail-closed)."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ label: str,
58
+ model: str = "fr_core_news_sm",
59
+ loader: Any | None = None,
60
+ ) -> None:
61
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
62
+ raise AdapterStepError(f"ner : label invalide {label!r}.")
63
+ self._label = label
64
+ self._model = model
65
+ #: Injectable en test : ``(model_name) -> nlp`` ; défaut = ``spacy.load``.
66
+ self._loader = loader
67
+ self._nlp: Any | None = None
68
+ self._model_version: str | None = None
69
+
70
+ @property
71
+ def name(self) -> str:
72
+ return f"ner:{self._label}"
73
+
74
+ @property
75
+ def version(self) -> str:
76
+ return _VERSION
77
+
78
+ @property
79
+ def input_types(self) -> frozenset[ArtifactType]:
80
+ return _INPUT_TYPES
81
+
82
+ @property
83
+ def output_types(self) -> frozenset[ArtifactType]:
84
+ return frozenset({ArtifactType.ENTITIES})
85
+
86
+ def system_binaries(self) -> dict[str, str]:
87
+ """Versions spaCy + modèle **si déjà chargés** (reproductibilité).
88
+
89
+ Best-effort sans effet de bord : ne déclenche aucun chargement — renvoie
90
+ ``{}`` tant que le modèle n'a pas tourné. Fusionné au ``RunManifest`` par
91
+ l'orchestrateur (duck-typing, comme le binaire tesseract).
92
+ """
93
+ if self._nlp is None:
94
+ return {}
95
+ out: dict[str, str] = {}
96
+ if self._model_version is not None:
97
+ out[f"spacy_model:{self._model}"] = self._model_version
98
+ try:
99
+ import spacy # type: ignore[import-not-found]
100
+
101
+ out["spacy"] = str(spacy.__version__)
102
+ except ImportError:
103
+ pass
104
+ return out
105
+
106
+ def _load(self) -> Any:
107
+ """Charge le modèle (idempotent). Fail-closed : lève si SDK/modèle absent."""
108
+ if self._nlp is not None:
109
+ return self._nlp
110
+ if self._loader is not None:
111
+ self._nlp = self._loader(self._model)
112
+ self._model_version = self._probe_model_version(self._nlp)
113
+ return self._nlp
114
+ try:
115
+ import spacy # type: ignore[import-not-found]
116
+ except ImportError as exc:
117
+ raise AdapterStepError(
118
+ "ner : spaCy non installé — `pip install 'cinoc[ner]'`."
119
+ ) from exc
120
+ try:
121
+ self._nlp = spacy.load(self._model)
122
+ except OSError as exc:
123
+ raise AdapterStepError(
124
+ f"ner : modèle spaCy {self._model!r} introuvable — "
125
+ f"`python -m spacy download {self._model}`."
126
+ ) from exc
127
+ self._model_version = self._probe_model_version(self._nlp)
128
+ return self._nlp
129
+
130
+ @staticmethod
131
+ def _probe_model_version(nlp: Any) -> str | None:
132
+ meta = getattr(nlp, "meta", None)
133
+ if isinstance(meta, dict):
134
+ version = meta.get("version")
135
+ return str(version) if version is not None else None
136
+ return None
137
+
138
+ def _extract(self, text: str) -> list[dict[str, object]]:
139
+ nlp = self._load()
140
+ doc = nlp(text)
141
+ entities: list[dict[str, object]] = []
142
+ for ent in doc.ents:
143
+ label = _DEFAULT_LABEL_MAPPING.get(ent.label_, ent.label_)
144
+ entities.append(
145
+ {
146
+ "label": label,
147
+ "start": int(ent.start_char),
148
+ "end": int(ent.end_char),
149
+ "text": ent.text,
150
+ }
151
+ )
152
+ return entities
153
+
154
+ def execute(
155
+ self,
156
+ inputs: dict[ArtifactType, Artifact],
157
+ params: dict[str, ParamValue],
158
+ context: RunContext,
159
+ control: RunControl,
160
+ ) -> StepOutput:
161
+ control.raise_if_cancelled()
162
+ source = inputs.get(ArtifactType.CORRECTED_TEXT) or inputs.get(
163
+ ArtifactType.RAW_TEXT
164
+ )
165
+ if source is None or source.uri is None:
166
+ raise AdapterStepError(
167
+ f"{self.name} : artefact texte (RAW_TEXT/CORRECTED_TEXT) manquant."
168
+ )
169
+ if context.workspace_uri is None:
170
+ raise AdapterStepError(f"{self.name} : workspace requis.")
171
+ text = _read_text(source.uri, self.name)
172
+ entities = self._extract(text)
173
+ payload = json.dumps(
174
+ {"text": text, "entities": entities}, ensure_ascii=False
175
+ ).encode("utf-8")
176
+ out_path = workspace_artifact_path(
177
+ context.workspace_uri, context.document_id, self._label, "entities.json"
178
+ )
179
+ out_path.write_bytes(payload)
180
+ return StepOutput(
181
+ artifacts={
182
+ ArtifactType.ENTITIES: Artifact(
183
+ id=f"{context.document_id}:{self.name}:entities",
184
+ document_id=context.document_id,
185
+ type=ArtifactType.ENTITIES,
186
+ uri=str(out_path),
187
+ content_hash=compute_content_hash(payload),
188
+ )
189
+ }
190
+ )
191
+
192
+
193
+ def _read_text(uri: str, name: str) -> str:
194
+ from pathlib import Path
195
+
196
+ try:
197
+ return Path(uri).read_text(encoding="utf-8")
198
+ except (OSError, UnicodeDecodeError) as exc:
199
+ raise AdapterStepError(
200
+ f"{name} : texte d'entrée illisible {uri!r} : {exc}"
201
+ ) from exc
202
+
203
+
204
+ __all__ = ["SpacyNerExtractor"]
cinoc/adapters/ocr/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Modules OCR/HTR (couche 5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cinoc.adapters.ocr.precomputed import PrecomputedTextAdapter
6
+ from cinoc.adapters.ocr.tesseract import TesseractAdapter
7
+
8
+ __all__ = ["PrecomputedTextAdapter", "TesseractAdapter"]
cinoc/adapters/ocr/_base.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Squelette commun des adapters OCR « image → un seul RAW_TEXT » (couche 5).
2
+
3
+ Le même ``execute()`` était recopié dans chaque adapter OCR mono-sortie : sonde
4
+ d'annulation, garde IMAGE, garde workspace, écriture du ``.txt`` dans le workspace,
5
+ construction de l'artefact ``RAW_TEXT`` (id ``{doc}:{name}:raw_text`` + hash du
6
+ texte). On le mutualise ici (miroir de ``llm/_base``) ; le **détail moteur** (params,
7
+ clé API, deadline) reste dans chaque adapter, porté par la closure ``recognize``.
8
+
9
+ N'est PAS pour les adapters multi-sorties (``tesseract`` : RAW_TEXT + CONFIDENCES
10
+ + ALTO optionnel) — eux gardent leur ``execute()`` propre. Contrat de l'artefact
11
+ ``RAW_TEXT`` figé par ``tests/adapters/test_ocr_artifact_contract_anchor``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable, Mapping
17
+
18
+ from cinoc.adapters._workspace import workspace_artifact_path
19
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
20
+ from cinoc.domain.errors import AdapterStepError
21
+ from cinoc.pipeline.run_control import RunControl
22
+ from cinoc.pipeline.types import RunContext, StepOutput
23
+
24
+
25
+ def run_image_to_text(
26
+ *,
27
+ name: str,
28
+ label: str,
29
+ inputs: Mapping[ArtifactType, Artifact],
30
+ context: RunContext,
31
+ control: RunControl,
32
+ recognize: Callable[[str], str],
33
+ ) -> StepOutput:
34
+ """Exécute un OCR image → ``RAW_TEXT`` unique, gardes + persistance mutualisées.
35
+
36
+ ``recognize(image_uri) -> texte`` porte la reconnaissance réelle (et ses gardes
37
+ propres : clé API, modèle…). Ordre identique à l'ancien code par adapter :
38
+ annulation → IMAGE → workspace → ``recognize`` → écriture ``.txt`` → artefact.
39
+ """
40
+ control.raise_if_cancelled()
41
+ image = inputs.get(ArtifactType.IMAGE)
42
+ if image is None or image.uri is None:
43
+ raise AdapterStepError(f"{name} : artefact IMAGE manquant ou sans URI.")
44
+ if context.workspace_uri is None:
45
+ raise AdapterStepError(f"{name} : workspace requis.")
46
+ text = recognize(image.uri)
47
+ output_path = workspace_artifact_path(
48
+ context.workspace_uri, context.document_id, label, "txt"
49
+ )
50
+ output_path.write_text(text, encoding="utf-8")
51
+ return StepOutput(
52
+ artifacts={
53
+ ArtifactType.RAW_TEXT: Artifact(
54
+ id=f"{context.document_id}:{name}:raw_text",
55
+ document_id=context.document_id,
56
+ type=ArtifactType.RAW_TEXT,
57
+ uri=str(output_path),
58
+ content_hash=compute_content_hash(text.encode("utf-8")),
59
+ )
60
+ }
61
+ )
62
+
63
+
64
+ __all__ = ["run_image_to_text"]
cinoc/adapters/ocr/azure_di.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``AzureDocIntelAdapter`` — OCR cloud (Azure AI Document Intelligence, modèle Read).
2
+
3
+ Moteur cloud **first-party**. API **REST** (``httpx``) plutôt que le SDK
4
+ ``azure-ai-documentintelligence`` (lourd, auth credential) : transport léger,
5
+ **testable par cassette HTTP**. Auth par **clé d'abonnement**
6
+ (``AZURE_DOC_INTEL_KEY``) + endpoint de la ressource (``AZURE_DOC_INTEL_ENDPOINT``).
7
+ Implémente le ``Module`` Protocol **directement** ; ``httpx`` est l'extra
8
+ ``cinoc[azure]``, importé **paresseusement** (mockable, CI sans clé ni réseau).
9
+
10
+ Flux **asynchrone** propre à Azure DI (fidèlement modélisé) : ``POST …:analyze``
11
+ renvoie ``202`` + un en-tête ``Operation-Location`` ; on **sonde** cette URL
12
+ (GET) jusqu'à ``status == "succeeded"`` (ou ``failed``/délai). Sortie :
13
+ ``analyzeResult.content`` du modèle ``prebuilt-read`` — le texte complet d'une page.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import time
20
+ from base64 import b64encode
21
+ from collections.abc import Callable
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from cinoc.adapters.ocr._base import run_image_to_text
26
+ from cinoc.domain.artifacts import Artifact, ArtifactType
27
+ from cinoc.domain.deadline import Deadline
28
+ from cinoc.domain.errors import AdapterStepError
29
+ from cinoc.pipeline.protocols import ParamValue
30
+ from cinoc.pipeline.run_control import RunControl
31
+ from cinoc.pipeline.types import RunContext, StepOutput
32
+
33
+ if TYPE_CHECKING: # annotations seules — pas d'import httpx au chargement du module
34
+ import httpx
35
+
36
+ _VERSION = "1.0"
37
+ _MODEL = "prebuilt-read"
38
+ _API_VERSION = "2024-11-30"
39
+ _ANALYZE_PATH = f"/documentintelligence/documentModels/{_MODEL}:analyze"
40
+ _KEY_HEADER = "Ocp-Apim-Subscription-Key"
41
+ _ENDPOINT_ENV = "AZURE_DOC_INTEL_ENDPOINT"
42
+ _KEY_ENV = "AZURE_DOC_INTEL_KEY"
43
+ #: Borne dure du *polling* (en plus de la ``Deadline``) — évite la boucle infinie
44
+ #: si le service reste indéfiniment ``running`` sous une deadline infinie.
45
+ _MAX_POLLS = 60
46
+
47
+
48
+ def _extract_text(data: object) -> str:
49
+ """Texte complet de ``analyzeResult.content`` ; page vide → ``""``."""
50
+ if not isinstance(data, dict):
51
+ raise AdapterStepError("azure_di : réponse JSON inattendue (objet).")
52
+ result = data.get("analyzeResult")
53
+ if not isinstance(result, dict):
54
+ raise AdapterStepError("azure_di : 'analyzeResult' absent de la réponse.")
55
+ content = result.get("content")
56
+ return str(content).strip() if content else ""
57
+
58
+
59
+ def _invoke_azure_di(
60
+ *,
61
+ endpoint: str,
62
+ image_path: str,
63
+ deadline: Deadline,
64
+ key: str,
65
+ transport: httpx.BaseTransport | None = None,
66
+ poll_interval: float = 1.0,
67
+ sleep: Callable[[float], None] = time.sleep,
68
+ ) -> str:
69
+ """Analyse l'image (POST → sonde l'``Operation-Location``) et renvoie le texte.
70
+
71
+ ``endpoint`` vient de la **configuration opérateur** (env), pas de
72
+ l'utilisateur → endpoint de confiance, pas de durcissement anti-SSRF (≠
73
+ importeurs de corpus). La clé est en **en-tête** (jamais journalisée : les
74
+ messages d'erreur ne portent que le statut). ``transport``/``sleep`` injectés
75
+ → cassette **et** sonde déterministes en test (aucune attente réelle).
76
+ """
77
+ try:
78
+ import httpx
79
+ except ImportError as exc:
80
+ raise AdapterStepError(
81
+ "azure_di : httpx non installé (pip install 'cinoc[azure]')."
82
+ ) from exc
83
+ content = b64encode(Path(image_path).read_bytes()).decode("ascii")
84
+ analyze_url = endpoint.rstrip("/") + _ANALYZE_PATH
85
+ with httpx.Client(
86
+ timeout=deadline.as_sdk_timeout(), transport=transport
87
+ ) as client:
88
+ operation_url = _submit_analysis(
89
+ client, analyze_url=analyze_url, key=key, content=content
90
+ )
91
+ return _poll_result(
92
+ client,
93
+ operation_url=operation_url,
94
+ key=key,
95
+ deadline=deadline,
96
+ poll_interval=poll_interval,
97
+ sleep=sleep,
98
+ )
99
+
100
+
101
+ def _submit_analysis(
102
+ client: httpx.Client, *, analyze_url: str, key: str, content: str
103
+ ) -> str:
104
+ """POST l'analyse ; renvoie l'URL ``Operation-Location`` à sonder."""
105
+ import httpx
106
+
107
+ headers = {_KEY_HEADER: key, "Content-Type": "application/json"}
108
+ try:
109
+ response = client.post(
110
+ analyze_url,
111
+ params={"api-version": _API_VERSION},
112
+ headers=headers,
113
+ json={"base64Source": content},
114
+ )
115
+ response.raise_for_status()
116
+ except httpx.HTTPStatusError as exc:
117
+ raise AdapterStepError(
118
+ f"azure_di : statut HTTP {exc.response.status_code} (soumission)."
119
+ ) from exc
120
+ except httpx.HTTPError as exc:
121
+ raise AdapterStepError(
122
+ f"azure_di : échec de transport (soumission) : {type(exc).__name__}."
123
+ ) from exc
124
+ operation_url = response.headers.get("operation-location")
125
+ if not operation_url:
126
+ raise AdapterStepError("azure_di : réponse sans en-tête Operation-Location.")
127
+ return str(operation_url)
128
+
129
+
130
+ def _poll_result(
131
+ client: httpx.Client,
132
+ *,
133
+ operation_url: str,
134
+ key: str,
135
+ deadline: Deadline,
136
+ poll_interval: float,
137
+ sleep: Callable[[float], None],
138
+ ) -> str:
139
+ """Sonde l'``Operation-Location`` jusqu'à un état terminal (ou délai)."""
140
+ import httpx
141
+
142
+ for _ in range(_MAX_POLLS):
143
+ try:
144
+ response = client.get(operation_url, headers={_KEY_HEADER: key})
145
+ response.raise_for_status()
146
+ data = response.json()
147
+ except httpx.HTTPStatusError as exc:
148
+ raise AdapterStepError(
149
+ f"azure_di : statut HTTP {exc.response.status_code} (sonde)."
150
+ ) from exc
151
+ except httpx.HTTPError as exc:
152
+ raise AdapterStepError(
153
+ f"azure_di : échec de transport (sonde) : {type(exc).__name__}."
154
+ ) from exc
155
+ status = (
156
+ str(data.get("status", "")).lower() if isinstance(data, dict) else ""
157
+ )
158
+ if status == "succeeded":
159
+ return _extract_text(data)
160
+ if status == "failed":
161
+ raise AdapterStepError("azure_di : analyse échouée (status 'failed').")
162
+ if deadline.is_expired():
163
+ break
164
+ sleep(poll_interval)
165
+ raise AdapterStepError("azure_di : analyse non terminée (délai de sonde dépassé).")
166
+
167
+
168
+ class AzureDocIntelAdapter:
169
+ """OCR cloud Azure DI : ``IMAGE`` → ``RAW_TEXT`` (texte ``prebuilt-read``)."""
170
+
171
+ def __init__(self, *, label: str) -> None:
172
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
173
+ raise AdapterStepError(f"azure_di : label invalide {label!r}.")
174
+ self._label = label
175
+
176
+ @property
177
+ def name(self) -> str:
178
+ return f"azure_di:{self._label}"
179
+
180
+ @property
181
+ def version(self) -> str:
182
+ return _VERSION
183
+
184
+ @property
185
+ def input_types(self) -> frozenset[ArtifactType]:
186
+ return frozenset({ArtifactType.IMAGE})
187
+
188
+ @property
189
+ def output_types(self) -> frozenset[ArtifactType]:
190
+ return frozenset({ArtifactType.RAW_TEXT})
191
+
192
+ def execute(
193
+ self,
194
+ inputs: dict[ArtifactType, Artifact],
195
+ params: dict[str, ParamValue],
196
+ context: RunContext,
197
+ control: RunControl,
198
+ ) -> StepOutput:
199
+ def recognize(image_uri: str) -> str:
200
+ endpoint = os.environ.get(_ENDPOINT_ENV)
201
+ key = os.environ.get(_KEY_ENV)
202
+ if not endpoint:
203
+ raise AdapterStepError(f"{self.name} : {_ENDPOINT_ENV} manquant.")
204
+ if not key:
205
+ raise AdapterStepError(f"{self.name} : {_KEY_ENV} manquante.")
206
+ return _invoke_azure_di(
207
+ endpoint=endpoint,
208
+ image_path=image_uri,
209
+ deadline=context.deadline,
210
+ key=key,
211
+ )
212
+
213
+ return run_image_to_text(
214
+ name=self.name,
215
+ label=self._label,
216
+ inputs=inputs,
217
+ context=context,
218
+ control=control,
219
+ recognize=recognize,
220
+ )
221
+
222
+
223
+ __all__ = ["AzureDocIntelAdapter"]
cinoc/adapters/ocr/calamari.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``CalamariAdapter`` — moteur OCR réel (calamari-ocr, reconnaissance de lignes).
2
+
3
+ Local et sans clé. Implémente le ``Module`` Protocol **directement** — même
4
+ contrat que le socle, livré in-tree (pas de discrimination socle/plugin). La lib
5
+ ``calamari_ocr`` est un **extra optionnel** (``cinoc[calamari]``), importée
6
+ **paresseusement** dans ``_invoke_calamari`` : importer ce module ne requiert rien ;
7
+ seule l'exécution exige la lib + un **modèle** (``model`` = checkpoint, obligatoire).
8
+
9
+ Calamari reconnaît une **ligne** par image : utilisé seul sur une page, il traite
10
+ l'image comme une ligne unique ; son usage naturel est en **recognizer par
11
+ région** dans le pipeline de segmentation (fan-out par bloc). Sortie : ``RAW_TEXT``.
12
+
13
+ **Non déployé au Space** (dép lourde TensorFlow, free-tier) : listé « indisponible »
14
+ sans l'extra, comme Kraken/PERO.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from cinoc.adapters.ocr._base import run_image_to_text
20
+ from cinoc.domain.artifacts import Artifact, ArtifactType
21
+ from cinoc.domain.errors import AdapterStepError
22
+ from cinoc.pipeline.protocols import ParamValue
23
+ from cinoc.pipeline.run_control import RunControl
24
+ from cinoc.pipeline.types import RunContext, StepOutput
25
+
26
+ _VERSION = "1.0"
27
+
28
+
29
+ def _invoke_calamari( # pragma: no cover -- lib + modèle requis (cf. 'live')
30
+ *, image_path: str, model_path: str
31
+ ) -> str:
32
+ """Reconnaissance Calamari de l'image (ligne) → texte prédit."""
33
+ try:
34
+ import numpy as np # type: ignore[import-not-found]
35
+ from calamari_ocr.ocr.predict.predictor import ( # type: ignore[import-not-found]
36
+ Predictor,
37
+ PredictorParams,
38
+ )
39
+ from PIL import Image # type: ignore[import-not-found]
40
+ except ImportError as exc:
41
+ raise AdapterStepError(
42
+ "calamari : lib non installée (pip install 'cinoc[calamari]')."
43
+ ) from exc
44
+ try:
45
+ predictor = Predictor.from_checkpoint(
46
+ params=PredictorParams(), checkpoint=model_path
47
+ )
48
+ except (OSError, ValueError, RuntimeError) as exc: # loader calamari
49
+ raise AdapterStepError(
50
+ f"calamari : modèle illisible {model_path!r} : {exc}"
51
+ ) from exc
52
+ image = np.array(Image.open(image_path).convert("L"))
53
+ predictions = predictor.predict_raw([image])
54
+ texts = [str(prediction.sentence) for prediction in predictions]
55
+ return " ".join(texts).strip()
56
+
57
+
58
+ class CalamariAdapter:
59
+ """OCR Calamari : ``IMAGE`` → ``RAW_TEXT`` (texte de la ligne reconnue)."""
60
+
61
+ def __init__(self, *, label: str, model: str) -> None:
62
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
63
+ raise AdapterStepError(f"calamari : label invalide {label!r}.")
64
+ if not model:
65
+ raise AdapterStepError(
66
+ "calamari : paramètre 'model' (checkpoint) requis."
67
+ )
68
+ self._label = label
69
+ self._model = model
70
+
71
+ @property
72
+ def name(self) -> str:
73
+ return f"calamari:{self._label}"
74
+
75
+ @property
76
+ def version(self) -> str:
77
+ return _VERSION
78
+
79
+ @property
80
+ def input_types(self) -> frozenset[ArtifactType]:
81
+ return frozenset({ArtifactType.IMAGE})
82
+
83
+ @property
84
+ def output_types(self) -> frozenset[ArtifactType]:
85
+ return frozenset({ArtifactType.RAW_TEXT})
86
+
87
+ def execute(
88
+ self,
89
+ inputs: dict[ArtifactType, Artifact],
90
+ params: dict[str, ParamValue],
91
+ context: RunContext,
92
+ control: RunControl,
93
+ ) -> StepOutput:
94
+ return run_image_to_text(
95
+ name=self.name,
96
+ label=self._label,
97
+ inputs=inputs,
98
+ context=context,
99
+ control=control,
100
+ recognize=lambda image_uri: _invoke_calamari(
101
+ image_path=image_uri, model_path=self._model
102
+ ),
103
+ )
104
+
105
+
106
+ __all__ = ["CalamariAdapter"]
cinoc/adapters/ocr/google_vision.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``GoogleVisionAdapter`` — OCR cloud (Google Cloud Vision REST), facturé à l'image.
2
+
3
+ Moteur cloud **first-party**. On vise l'**API REST** (``httpx``) plutôt que le SDK
4
+ ``google-cloud-vision`` (gRPC + auth ADC service-account, lourd et difficile à
5
+ rejouer) : transport léger, **testable par cassette HTTP**, auth par **clé d'API**
6
+ (``GOOGLE_VISION_API_KEY``). Implémente le ``Module`` Protocol **directement** ;
7
+ ``httpx`` est l'extra ``cinoc[google]``, importé **paresseusement** dans
8
+ ``_invoke_google_vision`` (mockable, CI sans clé ni réseau).
9
+
10
+ Sortie : ``fullTextAnnotation.text`` de ``DOCUMENT_TEXT_DETECTION`` — le texte
11
+ dense d'une page (le bon mode pour des documents, ≠ ``TEXT_DETECTION`` épars).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from base64 import b64encode
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING
20
+
21
+ from cinoc.adapters.ocr._base import run_image_to_text
22
+ from cinoc.domain.artifacts import Artifact, ArtifactType
23
+ from cinoc.domain.deadline import Deadline
24
+ from cinoc.domain.errors import AdapterStepError
25
+ from cinoc.pipeline.protocols import ParamValue
26
+ from cinoc.pipeline.run_control import RunControl
27
+ from cinoc.pipeline.types import RunContext, StepOutput
28
+
29
+ if TYPE_CHECKING: # annotations seules — pas d'import httpx au chargement du module
30
+ import httpx
31
+
32
+ _VERSION = "1.0"
33
+ _ENDPOINT = "https://vision.googleapis.com/v1/images:annotate"
34
+ _API_KEY_ENV = "GOOGLE_VISION_API_KEY"
35
+ _FEATURE = "DOCUMENT_TEXT_DETECTION"
36
+
37
+
38
+ def _extract_text(payload: object) -> str:
39
+ """Texte de la 1ʳᵉ réponse Vision ; erreur par image → ``AdapterStepError``.
40
+
41
+ Forme : ``{"responses": [{"fullTextAnnotation": {"text": …}}]}`` (ou
42
+ ``{"error": {"message": …}}`` pour un échec par image). Aucun texte détecté
43
+ (page blanche) → ``""`` — un résultat valide, pas une erreur.
44
+ """
45
+ if not isinstance(payload, dict):
46
+ raise AdapterStepError("google_vision : réponse JSON inattendue (objet).")
47
+ responses = payload.get("responses")
48
+ if not isinstance(responses, list) or not responses:
49
+ raise AdapterStepError("google_vision : réponse sans 'responses'.")
50
+ first = responses[0]
51
+ if not isinstance(first, dict):
52
+ raise AdapterStepError("google_vision : 'responses[0]' inattendu.")
53
+ error = first.get("error")
54
+ if isinstance(error, dict) and error:
55
+ message = error.get("message", "erreur sans message")
56
+ raise AdapterStepError(f"google_vision : erreur API : {message}")
57
+ annotation = first.get("fullTextAnnotation")
58
+ if isinstance(annotation, dict):
59
+ text = annotation.get("text")
60
+ return str(text).strip() if text else ""
61
+ return ""
62
+
63
+
64
+ def _invoke_google_vision(
65
+ *,
66
+ image_path: str,
67
+ deadline: Deadline,
68
+ api_key: str,
69
+ transport: httpx.BaseTransport | None = None,
70
+ ) -> str:
71
+ """POST l'image à Vision et renvoie le texte. ``transport`` injecté → cassette.
72
+
73
+ Endpoint **fixe et de confiance** (Google) : pas de durcissement anti-SSRF (≠
74
+ importeurs de corpus, dont l'URL vient de l'utilisateur). La clé d'API est en
75
+ *query string* (jamais journalisée : les messages d'erreur ne portent que le
76
+ statut). ``httpx`` paresseux : importer ce module n'exige pas l'extra.
77
+ """
78
+ try:
79
+ import httpx
80
+ except ImportError as exc:
81
+ raise AdapterStepError(
82
+ "google_vision : httpx non installé (pip install 'cinoc[google]')."
83
+ ) from exc
84
+ content = b64encode(Path(image_path).read_bytes()).decode("ascii")
85
+ body = {
86
+ "requests": [
87
+ {"image": {"content": content}, "features": [{"type": _FEATURE}]}
88
+ ]
89
+ }
90
+ try:
91
+ with httpx.Client(
92
+ timeout=deadline.as_sdk_timeout(), transport=transport
93
+ ) as client:
94
+ response = client.post(_ENDPOINT, params={"key": api_key}, json=body)
95
+ response.raise_for_status()
96
+ data = response.json()
97
+ except httpx.HTTPStatusError as exc:
98
+ raise AdapterStepError(
99
+ f"google_vision : statut HTTP {exc.response.status_code}."
100
+ ) from exc
101
+ except httpx.HTTPError as exc:
102
+ raise AdapterStepError(
103
+ f"google_vision : échec de transport : {type(exc).__name__}."
104
+ ) from exc
105
+ return _extract_text(data)
106
+
107
+
108
+ class GoogleVisionAdapter:
109
+ """OCR cloud Google Vision : ``IMAGE`` → ``RAW_TEXT`` (texte dense de la page)."""
110
+
111
+ def __init__(self, *, label: str) -> None:
112
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
113
+ raise AdapterStepError(f"google_vision : label invalide {label!r}.")
114
+ self._label = label
115
+
116
+ @property
117
+ def name(self) -> str:
118
+ return f"google_vision:{self._label}"
119
+
120
+ @property
121
+ def version(self) -> str:
122
+ return _VERSION
123
+
124
+ @property
125
+ def input_types(self) -> frozenset[ArtifactType]:
126
+ return frozenset({ArtifactType.IMAGE})
127
+
128
+ @property
129
+ def output_types(self) -> frozenset[ArtifactType]:
130
+ return frozenset({ArtifactType.RAW_TEXT})
131
+
132
+ def execute(
133
+ self,
134
+ inputs: dict[ArtifactType, Artifact],
135
+ params: dict[str, ParamValue],
136
+ context: RunContext,
137
+ control: RunControl,
138
+ ) -> StepOutput:
139
+ def recognize(image_uri: str) -> str:
140
+ api_key = os.environ.get(_API_KEY_ENV)
141
+ if not api_key:
142
+ raise AdapterStepError(f"{self.name} : {_API_KEY_ENV} manquante.")
143
+ return _invoke_google_vision(
144
+ image_path=image_uri, deadline=context.deadline, api_key=api_key
145
+ )
146
+
147
+ return run_image_to_text(
148
+ name=self.name,
149
+ label=self._label,
150
+ inputs=inputs,
151
+ context=context,
152
+ control=control,
153
+ recognize=recognize,
154
+ )
155
+
156
+
157
+ __all__ = ["GoogleVisionAdapter"]
cinoc/adapters/ocr/kraken.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``KrakenAdapter`` — moteur HTR réel (kraken, le moteur d'eScriptorium).
2
+
3
+ Implémente le ``Module`` Protocol directement. Couvre le **manuscrit** (HTR),
4
+ là où tesseract couvre l'imprimé. Le SDK ``kraken`` est un **extra optionnel**
5
+ (``cinoc[kraken]``), importé paresseusement dans ``_invoke_kraken`` — importer
6
+ ce module ne requiert rien ; seule l'exécution exige SDK + **modèle** (.mlmodel,
7
+ paramètre ``model`` obligatoire : pas de modèle par défaut embarqué).
8
+
9
+ Sorties : ``RAW_TEXT`` (lignes reconnues, ordre de lecture kraken) +
10
+ ``CONFIDENCES`` (un jeton par mot, confiance = moyenne des confiances de
11
+ caractères du mot — best-effort, sidecar vide en dégradé).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+
19
+ from cinoc.adapters._workspace import workspace_artifact_path
20
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
21
+ from cinoc.domain.confidence import ConfidenceToken
22
+ from cinoc.domain.errors import AdapterStepError
23
+ from cinoc.pipeline.protocols import ParamValue
24
+ from cinoc.pipeline.run_control import RunControl
25
+ from cinoc.pipeline.types import RunContext, StepOutput
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ _VERSION = "1.0"
30
+
31
+
32
+ def tokens_from_lines(
33
+ lines: list[tuple[str, list[float]]],
34
+ ) -> list[ConfidenceToken]:
35
+ """Jetons mot+confiance depuis (texte de ligne, confiances par caractère).
36
+
37
+ La confiance d'un mot = moyenne des confiances de ses caractères (bornée
38
+ [0,1]) ; un mot sans couverture de confiance est ignoré.
39
+ """
40
+ tokens: list[ConfidenceToken] = []
41
+ for text, confidences in lines:
42
+ cursor = 0
43
+ for word in text.split():
44
+ start = text.find(word, cursor)
45
+ if start < 0:
46
+ continue
47
+ cursor = start + len(word)
48
+ window = confidences[start : start + len(word)]
49
+ if not window:
50
+ continue
51
+ value = max(0.0, min(sum(window) / len(window), 1.0))
52
+ tokens.append(ConfidenceToken(text=word, confidence=value))
53
+ return tokens
54
+
55
+
56
+ def _invoke_kraken( # pragma: no cover -- SDK + modèle requis (cf. 'live')
57
+ *, image_path: str, model_path: str
58
+ ) -> list[tuple[str, list[float]]]:
59
+ """Segmentation + reconnaissance kraken → (texte, confiances/char) par ligne."""
60
+ try:
61
+ from kraken import ( # type: ignore[import-not-found]
62
+ binarization,
63
+ pageseg,
64
+ rpred,
65
+ )
66
+ from kraken.lib import models # type: ignore[import-not-found]
67
+ from PIL import Image # type: ignore[import-not-found]
68
+ except ImportError as exc:
69
+ raise AdapterStepError(
70
+ "kraken : SDK non installé (pip install 'cinoc[kraken]')."
71
+ ) from exc
72
+ try:
73
+ model = models.load_any(model_path)
74
+ except (OSError, ValueError, RuntimeError) as exc: # loader kraken
75
+ raise AdapterStepError(
76
+ f"kraken : modèle illisible {model_path!r} : {exc}"
77
+ ) from exc
78
+ image = Image.open(image_path)
79
+ binarized = binarization.nlbin(image)
80
+ segmentation = pageseg.segment(binarized)
81
+ out: list[tuple[str, list[float]]] = []
82
+ for record in rpred.rpred(model, binarized, segmentation):
83
+ out.append((str(record), [float(c) for c in record.confidences]))
84
+ return out
85
+
86
+
87
+ class KrakenAdapter:
88
+ """HTR kraken : ``IMAGE`` → ``RAW_TEXT`` + ``CONFIDENCES``."""
89
+
90
+ def __init__(self, *, label: str, model: str) -> None:
91
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
92
+ raise AdapterStepError(f"kraken : label invalide {label!r}.")
93
+ if not model:
94
+ raise AdapterStepError(
95
+ "kraken : paramètre 'model' (chemin .mlmodel) requis."
96
+ )
97
+ self._label = label
98
+ self._model = model
99
+
100
+ @property
101
+ def name(self) -> str:
102
+ return f"kraken:{self._label}"
103
+
104
+ @property
105
+ def version(self) -> str:
106
+ return _VERSION
107
+
108
+ @property
109
+ def input_types(self) -> frozenset[ArtifactType]:
110
+ return frozenset({ArtifactType.IMAGE})
111
+
112
+ @property
113
+ def output_types(self) -> frozenset[ArtifactType]:
114
+ return frozenset({ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES})
115
+
116
+ def execute(
117
+ self,
118
+ inputs: dict[ArtifactType, Artifact],
119
+ params: dict[str, ParamValue],
120
+ context: RunContext,
121
+ control: RunControl,
122
+ ) -> StepOutput:
123
+ control.raise_if_cancelled()
124
+ image = inputs.get(ArtifactType.IMAGE)
125
+ if image is None or image.uri is None:
126
+ raise AdapterStepError(
127
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
128
+ )
129
+ if context.workspace_uri is None:
130
+ raise AdapterStepError(f"{self.name} : workspace requis.")
131
+ lines = _invoke_kraken(image_path=image.uri, model_path=self._model)
132
+ text = "\n".join(line for line, _ in lines)
133
+ tokens = tokens_from_lines(lines)
134
+ text_path = workspace_artifact_path(
135
+ context.workspace_uri, context.document_id, self._label, "txt"
136
+ )
137
+ text_path.write_text(text, encoding="utf-8")
138
+ sidecar = json.dumps(
139
+ [token.model_dump() for token in tokens], ensure_ascii=False
140
+ ).encode("utf-8")
141
+ sidecar_path = workspace_artifact_path(
142
+ context.workspace_uri, context.document_id, self._label,
143
+ "confidences.json",
144
+ )
145
+ sidecar_path.write_bytes(sidecar)
146
+ return StepOutput(
147
+ artifacts={
148
+ ArtifactType.RAW_TEXT: Artifact(
149
+ id=f"{context.document_id}:{self.name}:raw_text",
150
+ document_id=context.document_id,
151
+ type=ArtifactType.RAW_TEXT,
152
+ uri=str(text_path),
153
+ content_hash=compute_content_hash(text.encode("utf-8")),
154
+ ),
155
+ ArtifactType.CONFIDENCES: Artifact(
156
+ id=f"{context.document_id}:{self.name}:confidences",
157
+ document_id=context.document_id,
158
+ type=ArtifactType.CONFIDENCES,
159
+ uri=str(sidecar_path),
160
+ content_hash=compute_content_hash(sidecar),
161
+ ),
162
+ }
163
+ )
164
+
165
+
166
+ __all__ = ["KrakenAdapter", "tokens_from_lines"]
cinoc/adapters/ocr/mistral_ocr.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``MistralOCRAdapter`` — OCR cloud (API Mistral OCR), facturé à la page.
2
+
3
+ Premier moteur **payant à la page** du socle : donne un sens réel à l'axe
4
+ coût du Pareto (économie T10, tarif ``cloud_page_kinds``). Implémente le
5
+ ``Module`` Protocol ; SDK ``mistralai`` = extra (``cinoc[mistral]``), importé
6
+ paresseusement dans ``_invoke_mistral_ocr`` (mockable, CI sans clé ni réseau).
7
+ Clé ``MISTRAL_API_KEY``. Sortie : le texte (markdown) des pages, concaténé.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from base64 import b64encode
13
+ from pathlib import Path
14
+
15
+ from cinoc.adapters.ocr._base import run_image_to_text
16
+ from cinoc.domain.artifacts import Artifact, ArtifactType
17
+ from cinoc.domain.deadline import Deadline
18
+ from cinoc.domain.errors import AdapterStepError
19
+ from cinoc.pipeline.protocols import ParamValue
20
+ from cinoc.pipeline.run_control import RunControl
21
+ from cinoc.pipeline.types import RunContext, StepOutput
22
+
23
+ _VERSION = "1.0"
24
+ _DEFAULT_MODEL = "mistral-ocr-latest"
25
+ _MEDIA = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
26
+ ".webp": "image/webp", ".tif": "image/tiff", ".tiff": "image/tiff"}
27
+
28
+
29
+ def _invoke_mistral_ocr( # pragma: no cover -- réseau + clé API (cf. 'live')
30
+ *, model: str, image_path: str, deadline: Deadline
31
+ ) -> str:
32
+ import os
33
+
34
+ api_key = os.environ.get("MISTRAL_API_KEY")
35
+ if not api_key:
36
+ raise AdapterStepError("mistral_ocr : MISTRAL_API_KEY manquante.")
37
+ try:
38
+ from mistralai import Mistral # type: ignore[import-not-found]
39
+ except ImportError as exc:
40
+ raise AdapterStepError(
41
+ "mistral_ocr : SDK non installé (pip install 'cinoc[mistral]')."
42
+ ) from exc
43
+ path = Path(image_path)
44
+ media = _MEDIA.get(path.suffix.lower(), "image/png")
45
+ image_b64 = b64encode(path.read_bytes()).decode("ascii")
46
+ client_kwargs: dict[str, object] = {"api_key": api_key}
47
+ timeout = deadline.as_sdk_timeout()
48
+ if timeout is not None:
49
+ client_kwargs["timeout_ms"] = int(timeout * 1000)
50
+ try:
51
+ client = Mistral(**client_kwargs) # type: ignore[arg-type]
52
+ response = client.ocr.process(
53
+ model=model,
54
+ document={
55
+ "type": "image_url",
56
+ "image_url": f"data:{media};base64,{image_b64}",
57
+ },
58
+ )
59
+ except Exception as exc: # le SDK lève des erreurs HTTP/SDK variées
60
+ raise AdapterStepError(
61
+ f"mistral_ocr a échoué ({model}) : {type(exc).__name__}: {exc}"
62
+ ) from exc
63
+ pages = getattr(response, "pages", None) or []
64
+ return "\n\n".join(
65
+ str(getattr(page, "markdown", "") or "") for page in pages
66
+ ).strip()
67
+
68
+
69
+ class MistralOCRAdapter:
70
+ """OCR cloud Mistral : ``IMAGE`` → ``RAW_TEXT`` (markdown des pages)."""
71
+
72
+ def __init__(self, *, label: str, model: str = _DEFAULT_MODEL) -> None:
73
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
74
+ raise AdapterStepError(f"mistral_ocr : label invalide {label!r}.")
75
+ self._label = label
76
+ self._model = model
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return f"mistral_ocr:{self._label}"
81
+
82
+ @property
83
+ def version(self) -> str:
84
+ return _VERSION
85
+
86
+ @property
87
+ def input_types(self) -> frozenset[ArtifactType]:
88
+ return frozenset({ArtifactType.IMAGE})
89
+
90
+ @property
91
+ def output_types(self) -> frozenset[ArtifactType]:
92
+ return frozenset({ArtifactType.RAW_TEXT})
93
+
94
+ def execute(
95
+ self,
96
+ inputs: dict[ArtifactType, Artifact],
97
+ params: dict[str, ParamValue],
98
+ context: RunContext,
99
+ control: RunControl,
100
+ ) -> StepOutput:
101
+ return run_image_to_text(
102
+ name=self.name,
103
+ label=self._label,
104
+ inputs=inputs,
105
+ context=context,
106
+ control=control,
107
+ recognize=lambda image_uri: _invoke_mistral_ocr(
108
+ model=self._model, image_path=image_uri, deadline=context.deadline
109
+ ),
110
+ )
111
+
112
+
113
+ __all__ = ["MistralOCRAdapter"]
cinoc/adapters/ocr/pero.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``PeroAdapter`` — moteur OCR/HTR réel (pero-ocr).
2
+
3
+ Local et sans clé : couvre l'imprimé ancien et le manuscrit via les modèles PERO,
4
+ là où tesseract couvre l'imprimé moderne. Implémente le ``Module`` Protocol
5
+ **directement** — même contrat que le socle, livré in-tree (décision : pas de
6
+ discrimination socle/plugin ; seule la livraison diffère). La lib ``pero_ocr`` est
7
+ un **extra optionnel** (``cinoc[pero]``), importée **paresseusement** dans
8
+ ``_invoke_pero`` : importer ce module ne requiert rien ; seule l'exécution exige la
9
+ lib + un **modèle** (``model`` = chemin du fichier de config PERO, obligatoire —
10
+ pas de modèle embarqué). Sortie : ``RAW_TEXT`` (lignes reconnues, ordre PERO).
11
+
12
+ **Non déployé au Space** (dép lourde, free-tier) : listé « indisponible » sans
13
+ l'extra, comme Kraken — pas d'exécution cloud, pas de clé.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from cinoc.adapters.ocr._base import run_image_to_text
21
+ from cinoc.domain.artifacts import Artifact, ArtifactType
22
+ from cinoc.domain.errors import AdapterStepError
23
+ from cinoc.pipeline.protocols import ParamValue
24
+ from cinoc.pipeline.run_control import RunControl
25
+ from cinoc.pipeline.types import RunContext, StepOutput
26
+
27
+ _VERSION = "1.0"
28
+
29
+
30
+ def _invoke_pero( # pragma: no cover -- lib + modèle requis (cf. 'live')
31
+ *, image_path: str, config_path: str
32
+ ) -> str:
33
+ """Mise en page + reconnaissance PERO → texte (lignes jointes, ordre PERO)."""
34
+ try:
35
+ import configparser
36
+
37
+ import cv2 # type: ignore[import-not-found]
38
+ from pero_ocr.document_ocr.layout import ( # type: ignore[import-not-found]
39
+ PageLayout,
40
+ )
41
+ from pero_ocr.document_ocr.page_parser import ( # type: ignore[import-not-found]
42
+ PageParser,
43
+ )
44
+ except ImportError as exc:
45
+ raise AdapterStepError(
46
+ "pero : lib non installée (pip install 'cinoc[pero]')."
47
+ ) from exc
48
+ config = configparser.ConfigParser()
49
+ if not config.read(config_path):
50
+ raise AdapterStepError(f"pero : config illisible {config_path!r}.")
51
+ image = cv2.imread(image_path)
52
+ if image is None:
53
+ raise AdapterStepError(f"pero : image illisible {image_path!r}.")
54
+ parser = PageParser(config, config_path=str(Path(config_path).parent))
55
+ layout = PageLayout(id="page", page_size=(image.shape[0], image.shape[1]))
56
+ layout = parser.process_page(image, layout)
57
+ lines = [
58
+ str(line.transcription)
59
+ for region in layout.regions
60
+ for line in region.lines
61
+ if line.transcription
62
+ ]
63
+ return "\n".join(lines).strip()
64
+
65
+
66
+ class PeroAdapter:
67
+ """OCR/HTR PERO : ``IMAGE`` → ``RAW_TEXT`` (lignes reconnues)."""
68
+
69
+ def __init__(self, *, label: str, model: str) -> None:
70
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
71
+ raise AdapterStepError(f"pero : label invalide {label!r}.")
72
+ if not model:
73
+ raise AdapterStepError(
74
+ "pero : paramètre 'model' (chemin de config PERO) requis."
75
+ )
76
+ self._label = label
77
+ self._model = model
78
+
79
+ @property
80
+ def name(self) -> str:
81
+ return f"pero:{self._label}"
82
+
83
+ @property
84
+ def version(self) -> str:
85
+ return _VERSION
86
+
87
+ @property
88
+ def input_types(self) -> frozenset[ArtifactType]:
89
+ return frozenset({ArtifactType.IMAGE})
90
+
91
+ @property
92
+ def output_types(self) -> frozenset[ArtifactType]:
93
+ return frozenset({ArtifactType.RAW_TEXT})
94
+
95
+ def execute(
96
+ self,
97
+ inputs: dict[ArtifactType, Artifact],
98
+ params: dict[str, ParamValue],
99
+ context: RunContext,
100
+ control: RunControl,
101
+ ) -> StepOutput:
102
+ return run_image_to_text(
103
+ name=self.name,
104
+ label=self._label,
105
+ inputs=inputs,
106
+ context=context,
107
+ control=control,
108
+ recognize=lambda image_uri: _invoke_pero(
109
+ image_path=image_uri, config_path=self._model
110
+ ),
111
+ )
112
+
113
+
114
+ __all__ = ["PeroAdapter"]
cinoc/adapters/ocr/precomputed.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``PrecomputedTextAdapter`` — rejoue un texte OCR pré-calculé depuis le disque.
2
+
3
+ Module à **zéro dépendance et zéro réseau** : il lit ``<stem>.<source_label>.txt``
4
+ à côté de l'image et renvoie un ``RAW_TEXT``. Brique du *starter pack* — il fait
5
+ tourner le squelette ambulant (``cinoc demo``) et les tests déterministes sans
6
+ moteur réel. Implémente le ``Module`` Protocol (couche 4) **directement**.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
14
+ from cinoc.domain.errors import AdapterStepError
15
+ from cinoc.pipeline.protocols import ParamValue
16
+ from cinoc.pipeline.run_control import RunControl
17
+ from cinoc.pipeline.types import RunContext, StepOutput
18
+
19
+ _VERSION = "1.0"
20
+
21
+
22
+ class PrecomputedTextAdapter:
23
+ """Lit un texte OCR pré-calculé (``<stem>.<label>.txt`` près de l'image)."""
24
+
25
+ def __init__(self, *, source_label: str) -> None:
26
+ if not source_label or not all(
27
+ c.isalnum() or c in "_-" for c in source_label
28
+ ):
29
+ raise AdapterStepError(
30
+ f"PrecomputedTextAdapter : source_label invalide "
31
+ f"{source_label!r} (alphanumérique + _ - uniquement)."
32
+ )
33
+ self._label = source_label
34
+
35
+ @property
36
+ def name(self) -> str:
37
+ return f"precomputed:{self._label}"
38
+
39
+ @property
40
+ def version(self) -> str:
41
+ return _VERSION
42
+
43
+ @property
44
+ def input_types(self) -> frozenset[ArtifactType]:
45
+ return frozenset({ArtifactType.IMAGE})
46
+
47
+ @property
48
+ def output_types(self) -> frozenset[ArtifactType]:
49
+ return frozenset({ArtifactType.RAW_TEXT})
50
+
51
+ def execute(
52
+ self,
53
+ inputs: dict[ArtifactType, Artifact],
54
+ params: dict[str, ParamValue],
55
+ context: RunContext,
56
+ control: RunControl,
57
+ ) -> StepOutput:
58
+ control.raise_if_cancelled()
59
+ image = inputs.get(ArtifactType.IMAGE)
60
+ if image is None or image.uri is None:
61
+ raise AdapterStepError(
62
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
63
+ )
64
+ image_path = Path(image.uri)
65
+ text_path = image_path.parent / f"{image_path.stem}.{self._label}.txt"
66
+ if not text_path.is_file():
67
+ raise AdapterStepError(
68
+ f"{self.name} : texte pré-calculé introuvable — "
69
+ f"{text_path.name!r} attendu près de {image_path.name!r}."
70
+ )
71
+ data = text_path.read_bytes()
72
+ try:
73
+ data.decode("utf-8")
74
+ except UnicodeDecodeError as exc:
75
+ raise AdapterStepError(
76
+ f"{self.name} : {text_path.name!r} n'est pas en UTF-8 : {exc}"
77
+ ) from exc
78
+ return StepOutput(
79
+ artifacts={
80
+ ArtifactType.RAW_TEXT: Artifact(
81
+ id=f"{context.document_id}:{self.name}:raw_text",
82
+ document_id=context.document_id,
83
+ type=ArtifactType.RAW_TEXT,
84
+ uri=str(text_path),
85
+ content_hash=compute_content_hash(data),
86
+ )
87
+ }
88
+ )
89
+
90
+
91
+ __all__ = ["PrecomputedTextAdapter"]
cinoc/adapters/ocr/tesseract.py ADDED
@@ -0,0 +1,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``TesseractAdapter`` — moteur OCR réel (Tesseract 5 via ``pytesseract``).
2
+
3
+ Implémente le ``Module`` Protocol (couche 4) **directement**. L'OCR a lieu dans
4
+ le binaire C ``tesseract`` lancé en sous-processus (le thread relâche le GIL).
5
+ ``pytesseract`` est un **extra optionnel** (``cinoc[tesseract]``), importé
6
+ **paresseusement** dans ``_invoke_tesseract`` : importer ce module ne requiert ni
7
+ la lib ni le binaire — seule l'exécution les exige.
8
+
9
+ Sécurité : ``lang`` est validé (anti-injection ligne de commande) ; ``timeout``
10
+ est borné par la ``Deadline`` (un sous-processus figé ne doit pas geler le run).
11
+ Les **confidences** par mot (TSV natif, 0-100 → [0,1]) sont écrites en
12
+ sidecar JSON (``ConfidenceToken``) et publiées comme artefact ``CONFIDENCES``
13
+ — best-effort : un échec d'extraction dégrade en sidecar vide, jamais en
14
+ panne de l'OCR.
15
+
16
+ **ALTO natif** (``alto=True``) : Tesseract émet en plus un artefact
17
+ ``ALTO_XML`` (géométrie + texte par mot, ``image_to_alto_xml``) — la forme
18
+ ré-importable dans un outil de relecture (eScriptorium, Transkribus). Contrairement
19
+ aux confidences, l'ALTO est ici le **livrable demandé** : un échec d'émission lève
20
+ (``AdapterStepError``), il ne dégrade pas en silence.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import logging
27
+ import re
28
+ import subprocess
29
+ from collections.abc import Callable
30
+
31
+ from cinoc.adapters._workspace import workspace_artifact_path
32
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
33
+ from cinoc.domain.confidence import ConfidenceToken
34
+ from cinoc.domain.errors import AdapterStepError
35
+ from cinoc.pipeline.protocols import ParamValue
36
+ from cinoc.pipeline.run_control import RunControl
37
+ from cinoc.pipeline.types import RunContext, StepOutput
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ _VERSION = "1.0"
42
+ _DEFAULT_TIMEOUT = 120.0
43
+
44
+ #: Codes langue Tesseract : ISO 639-3 (≥3 lettres ASCII), combinables par ``+``
45
+ #: (``fra+lat``). ``lang`` finit sur la ligne de commande tesseract → on refuse
46
+ #: tout caractère interprétable comme flag/séparateur (anti-injection).
47
+ _LANG_RE = re.compile(r"^[a-zA-Z]{3,}(?:\+[a-zA-Z]{3,})*$")
48
+
49
+ #: Lanceur de sous-processus, injectable → version sondable en test sans binaire.
50
+ BinaryRunner = Callable[..., "subprocess.CompletedProcess[str]"]
51
+
52
+
53
+ def tesseract_binary_version(*, run: BinaryRunner = subprocess.run) -> str | None:
54
+ """Version du binaire ``tesseract`` (1ʳᵉ ligne de ``tesseract --version``).
55
+
56
+ **Best-effort, jamais bloquant** : binaire absent, timeout, ou sortie illisible
57
+ → ``None`` (jamais une panne). Sert la **reproductibilité** (``RunManifest`` §12) :
58
+ deux runs ne sont comparables qu'à version de binaire égale — la version
59
+ d'*adapter* (``_VERSION``) ne dit rien du Tesseract réellement installé.
60
+ ``run`` est injecté → la sonde est déterministe en test, sans le binaire.
61
+
62
+ Tesseract émet sa bannière tantôt sur ``stdout`` tantôt sur ``stderr`` selon le
63
+ build → on lit les deux et garde la 1ʳᵉ ligne non vide (ex. ``tesseract 5.3.0``).
64
+ """
65
+ try:
66
+ completed = run(
67
+ ["tesseract", "--version"],
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=10,
71
+ check=False,
72
+ )
73
+ except (OSError, subprocess.SubprocessError):
74
+ return None
75
+ lines = ((completed.stdout or "") + "\n" + (completed.stderr or "")).splitlines()
76
+ for line in lines:
77
+ if line.strip():
78
+ return line.strip()
79
+ return None
80
+
81
+
82
+ def _invoke_tesseract( # pragma: no cover -- binaire requis (cf. marqueur 'live')
83
+ *, image_path: str, lang: str, psm: int, oem: int, timeout: float
84
+ ) -> str:
85
+ """Lance tesseract et renvoie le texte. **Isolé → mockable** (CI sans binaire)."""
86
+ try:
87
+ import pytesseract # type: ignore[import-not-found, import-untyped]
88
+ except ImportError as exc:
89
+ raise AdapterStepError(
90
+ "tesseract : pytesseract non installé "
91
+ "(pip install 'cinoc[tesseract]' + binaire tesseract)."
92
+ ) from exc
93
+ config = f"--oem {oem} --psm {psm}"
94
+ try:
95
+ text = pytesseract.image_to_string(
96
+ image_path, lang=lang, config=config, timeout=timeout
97
+ )
98
+ except (
99
+ pytesseract.TesseractNotFoundError,
100
+ pytesseract.TesseractError,
101
+ RuntimeError,
102
+ ) as exc:
103
+ raise AdapterStepError(
104
+ f"tesseract a échoué sur {image_path!r} : {type(exc).__name__}: {exc}"
105
+ ) from exc
106
+ return str(text).strip()
107
+
108
+
109
+ def _invoke_tesseract_alto( # pragma: no cover -- binaire requis ('live')
110
+ *, image_path: str, lang: str, psm: int, oem: int, timeout: float
111
+ ) -> bytes:
112
+ """ALTO XML natif via ``image_to_alto_xml`` (géométrie + texte par mot).
113
+
114
+ Renvoie les octets XML bruts (déjà conformes ALTO) — destinés à la
115
+ ré-importation dans un outil de relecture. Une panne du binaire lève
116
+ ``AdapterStepError`` : l'ALTO est le livrable demandé, pas un extra dégradable.
117
+ """
118
+ try:
119
+ import pytesseract # type: ignore[import-not-found, import-untyped]
120
+ except ImportError as exc:
121
+ raise AdapterStepError(
122
+ "tesseract : pytesseract non installé "
123
+ "(pip install 'cinoc[tesseract]' + binaire tesseract)."
124
+ ) from exc
125
+ try:
126
+ xml = pytesseract.image_to_alto_xml(
127
+ image_path, lang=lang, config=f"--oem {oem} --psm {psm}", timeout=timeout
128
+ )
129
+ except (
130
+ pytesseract.TesseractNotFoundError,
131
+ pytesseract.TesseractError,
132
+ RuntimeError,
133
+ ) as exc:
134
+ raise AdapterStepError(
135
+ f"tesseract ALTO a échoué sur {image_path!r} : "
136
+ f"{type(exc).__name__}: {exc}"
137
+ ) from exc
138
+ return bytes(xml)
139
+
140
+
141
+ def _invoke_tesseract_confidences( # pragma: no cover -- binaire requis ('live')
142
+ *, image_path: str, lang: str, psm: int, oem: int, timeout: float
143
+ ) -> list[ConfidenceToken]:
144
+ """Confidences par mot via ``image_to_data`` (TSV natif, conf 0-100)."""
145
+ import pytesseract # type: ignore[import-not-found]
146
+
147
+ data = pytesseract.image_to_data(
148
+ image_path,
149
+ lang=lang,
150
+ config=f"--psm {psm} --oem {oem}",
151
+ timeout=timeout,
152
+ output_type=pytesseract.Output.DICT,
153
+ )
154
+ tokens: list[ConfidenceToken] = []
155
+ for word, conf in zip(data["text"], data["conf"], strict=False):
156
+ if not isinstance(word, str) or not word.strip():
157
+ continue
158
+ value = float(conf)
159
+ if value < 0: # -1 : entrée non textuelle du TSV
160
+ continue
161
+ tokens.append(
162
+ ConfidenceToken(text=word.strip(), confidence=min(value / 100.0, 1.0))
163
+ )
164
+ return tokens
165
+
166
+
167
+ class TesseractAdapter:
168
+ """OCR Tesseract 5 ; écrit le texte dans le workspace, renvoie un ``RAW_TEXT``."""
169
+
170
+ def __init__(
171
+ self,
172
+ *,
173
+ label: str,
174
+ lang: str = "fra",
175
+ psm: int = 6,
176
+ oem: int = 3,
177
+ alto: bool = False,
178
+ ) -> None:
179
+ if not label or not all(c.isalnum() or c in "_-" for c in label):
180
+ raise AdapterStepError(
181
+ f"TesseractAdapter : label invalide {label!r} "
182
+ "(alphanumérique + _ - uniquement)."
183
+ )
184
+ if not _LANG_RE.fullmatch(lang):
185
+ raise AdapterStepError(
186
+ f"TesseractAdapter : lang invalide {lang!r} "
187
+ "(ISO 639-3, combinable par '+' : ex. 'fra+lat')."
188
+ )
189
+ if not 0 <= psm <= 13:
190
+ raise AdapterStepError(f"TesseractAdapter : psm ∈ [0, 13], reçu {psm}.")
191
+ if not 0 <= oem <= 3:
192
+ raise AdapterStepError(f"TesseractAdapter : oem ∈ [0, 3], reçu {oem}.")
193
+ self._label = label
194
+ self._lang = lang
195
+ self._psm = psm
196
+ self._oem = oem
197
+ #: Émet en plus un artefact ``ALTO_XML`` (ré-import eScriptorium/Transkribus).
198
+ self._alto = alto
199
+
200
+ @property
201
+ def name(self) -> str:
202
+ return f"tesseract:{self._label}"
203
+
204
+ @property
205
+ def version(self) -> str:
206
+ return _VERSION
207
+
208
+ def system_binaries(self) -> dict[str, str]:
209
+ """Version du **binaire externe** pour le ``RunManifest`` (reproductibilité).
210
+
211
+ Hook de **provenance optionnel** (hors ``Module`` Protocol, qui reste le
212
+ contrat d'exécution unique) : l'orchestrateur l'appelle par *duck-typing*
213
+ sur les modules qui l'exposent et fusionne le résultat dans
214
+ ``system_binaries_lock``. Best-effort : binaire absent → ``{}``.
215
+ """
216
+ version = tesseract_binary_version()
217
+ return {"tesseract": version} if version is not None else {}
218
+
219
+ @property
220
+ def input_types(self) -> frozenset[ArtifactType]:
221
+ return frozenset({ArtifactType.IMAGE})
222
+
223
+ @property
224
+ def output_types(self) -> frozenset[ArtifactType]:
225
+ types = {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES}
226
+ if self._alto:
227
+ types.add(ArtifactType.ALTO_XML)
228
+ return frozenset(types)
229
+
230
+ def execute(
231
+ self,
232
+ inputs: dict[ArtifactType, Artifact],
233
+ params: dict[str, ParamValue],
234
+ context: RunContext,
235
+ control: RunControl,
236
+ ) -> StepOutput:
237
+ control.raise_if_cancelled()
238
+ image = inputs.get(ArtifactType.IMAGE)
239
+ if image is None or image.uri is None:
240
+ raise AdapterStepError(
241
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
242
+ )
243
+ if context.workspace_uri is None:
244
+ raise AdapterStepError(
245
+ f"{self.name} : workspace requis (RunContext.workspace_uri)."
246
+ )
247
+ timeout = max(0.001, context.deadline.clamp_to_remaining(_DEFAULT_TIMEOUT))
248
+ text = _invoke_tesseract(
249
+ image_path=image.uri,
250
+ lang=self._lang,
251
+ psm=self._psm,
252
+ oem=self._oem,
253
+ timeout=timeout,
254
+ )
255
+ output_path = workspace_artifact_path(
256
+ context.workspace_uri, context.document_id, self._label, "txt"
257
+ )
258
+ output_path.write_text(text, encoding="utf-8")
259
+ # Confidences best-effort : un échec d'extraction ne doit pas faire
260
+ # échouer un OCR réussi — sidecar vide + avertissement.
261
+ try:
262
+ tokens = _invoke_tesseract_confidences(
263
+ image_path=image.uri,
264
+ lang=self._lang,
265
+ psm=self._psm,
266
+ oem=self._oem,
267
+ timeout=timeout,
268
+ )
269
+ except (
270
+ AdapterStepError,
271
+ ImportError,
272
+ RuntimeError,
273
+ ValueError,
274
+ OSError,
275
+ ) as exc:
276
+ logger.warning(
277
+ "[tesseract] confidences dégradées (sidecar vide) : %s", exc
278
+ )
279
+ tokens = []
280
+ sidecar = json.dumps(
281
+ [token.model_dump() for token in tokens], ensure_ascii=False
282
+ ).encode("utf-8")
283
+ sidecar_path = workspace_artifact_path(
284
+ context.workspace_uri,
285
+ context.document_id,
286
+ self._label,
287
+ "confidences.json",
288
+ )
289
+ sidecar_path.write_bytes(sidecar)
290
+ artifacts: dict[ArtifactType, Artifact] = {
291
+ ArtifactType.RAW_TEXT: Artifact(
292
+ id=f"{context.document_id}:{self.name}:raw_text",
293
+ document_id=context.document_id,
294
+ type=ArtifactType.RAW_TEXT,
295
+ uri=str(output_path),
296
+ content_hash=compute_content_hash(text.encode("utf-8")),
297
+ ),
298
+ ArtifactType.CONFIDENCES: Artifact(
299
+ id=f"{context.document_id}:{self.name}:confidences",
300
+ document_id=context.document_id,
301
+ type=ArtifactType.CONFIDENCES,
302
+ uri=str(sidecar_path),
303
+ content_hash=compute_content_hash(sidecar),
304
+ ),
305
+ }
306
+ if self._alto:
307
+ # ALTO demandé → livrable : un échec lève (≠ confidences best-effort).
308
+ alto_bytes = _invoke_tesseract_alto(
309
+ image_path=image.uri,
310
+ lang=self._lang,
311
+ psm=self._psm,
312
+ oem=self._oem,
313
+ timeout=timeout,
314
+ )
315
+ alto_path = workspace_artifact_path(
316
+ context.workspace_uri,
317
+ context.document_id,
318
+ self._label,
319
+ "alto.xml",
320
+ )
321
+ alto_path.write_bytes(alto_bytes)
322
+ artifacts[ArtifactType.ALTO_XML] = Artifact(
323
+ id=f"{context.document_id}:{self.name}:alto_xml",
324
+ document_id=context.document_id,
325
+ type=ArtifactType.ALTO_XML,
326
+ uri=str(alto_path),
327
+ content_hash=compute_content_hash(alto_bytes),
328
+ )
329
+ return StepOutput(artifacts=artifacts)
330
+
331
+
332
+ __all__ = ["TesseractAdapter", "tesseract_binary_version"]
cinoc/adapters/preprocess/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Prétraitement d'image (couche 5) : ``IMAGE → IMAGE``."""
2
+
3
+ from cinoc.adapters.preprocess.image_prep import ImagePreprocessor
4
+
5
+ __all__ = ["ImagePreprocessor"]
cinoc/adapters/preprocess/_ops.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Opérations de prétraitement — **numpy pur**, sans décodage d'image.
2
+
3
+ Séparées de l'adapter pour qu'elles se testent sur des matrices construites à la
4
+ main, sans Pillow et sans fichier. C'est la même discipline que les métriques :
5
+ les maths d'un côté, l'entrée/sortie de l'autre.
6
+
7
+ **Aucun seuil arbitraire.** C'est une contrainte de conception, pas un hasard :
8
+ une mesure de qualité d'image a été retirée de ce dépôt précisément parce que son
9
+ score composite reposait sur des constantes non validées (D-190). Ici, le seuil
10
+ de binarisation est **calculé sur l'image** (Otsu maximise la variance
11
+ inter-classes), et l'angle de redressement est **cherché**, pas postulé. Les deux
12
+ seules constantes sont des **bornes de recherche** — l'amplitude et le pas —, qui
13
+ ne décident de rien : elles disent seulement où l'on cherche.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+
21
+ def to_grayscale(image: np.ndarray) -> np.ndarray:
22
+ """RVB → niveaux de gris (luminance ITU-R BT.601), en ``uint8``.
23
+
24
+ Déjà en gris (2 dimensions) → renvoyé tel quel : l'opération est idempotente,
25
+ ce qui permet de la chaîner sans y penser.
26
+ """
27
+ if image.ndim == 2:
28
+ return image.astype(np.uint8, copy=False)
29
+ poids = np.array([0.299, 0.587, 0.114], dtype=np.float64)
30
+ gris = image[..., :3].astype(np.float64) @ poids
31
+ return np.clip(np.rint(gris), 0, 255).astype(np.uint8)
32
+
33
+
34
+ def otsu_threshold(gray: np.ndarray) -> int:
35
+ """Seuil d'Otsu : celui qui **maximise la variance inter-classes**.
36
+
37
+ Calculé sur l'histogramme de l'image, donc propre à elle : deux pages
38
+ différemment exposées reçoivent deux seuils différents, ce qu'une constante
39
+ ne saurait pas faire. Image uniforme → renvoie sa valeur (aucune séparation
40
+ n'a de sens, et un seuil médian inventerait du contraste).
41
+ """
42
+ histogramme = np.bincount(gray.reshape(-1), minlength=256).astype(np.float64)
43
+ total = histogramme.sum()
44
+ if total == 0:
45
+ return 0
46
+ niveaux = np.arange(256, dtype=np.float64)
47
+ poids_bas = np.cumsum(histogramme)
48
+ poids_haut = total - poids_bas
49
+ somme_totale = float(niveaux @ histogramme)
50
+ somme_bas = np.cumsum(niveaux * histogramme)
51
+ # Classes vides → variance nulle : on les écarte au lieu de diviser par zéro.
52
+ valide = (poids_bas > 0) & (poids_haut > 0)
53
+ if not valide.any():
54
+ return int(gray.reshape(-1)[0])
55
+ moy_bas = np.divide(somme_bas, poids_bas, out=np.zeros(256), where=valide)
56
+ moy_haut = np.divide(
57
+ somme_totale - somme_bas, poids_haut, out=np.zeros(256), where=valide
58
+ )
59
+ inter = poids_bas * poids_haut * (moy_bas - moy_haut) ** 2
60
+ inter[~valide] = -1.0
61
+ return int(np.argmax(inter))
62
+
63
+
64
+ def binarize(gray: np.ndarray, threshold: int) -> np.ndarray:
65
+ """Gris → noir et blanc (0 / 255) au seuil donné. Encre = sombre = 0."""
66
+ return np.where(gray > threshold, np.uint8(255), np.uint8(0))
67
+
68
+
69
+ def estimate_skew(
70
+ gray: np.ndarray, *, amplitude_deg: float = 5.0, pas_deg: float = 0.5
71
+ ) -> float:
72
+ """Angle d'inclinaison, en degrés, par profil de projection.
73
+
74
+ Une page droite a des lignes de texte horizontales : la somme de l'encre par
75
+ rangée y est donc très **contrastée** — des pics sur le texte, des creux
76
+ entre les lignes. Incliner la page lisse ce profil. On cherche l'angle qui
77
+ maximise la variance du profil : c'est celui qui remet les lignes à plat.
78
+
79
+ **L'accumulateur a une hauteur fixe**, dimensionnée pour l'angle maximal, et
80
+ rien n'y est écrêté. C'est la seule façon de comparer des angles entre eux :
81
+ une première version rabattait les rangées hors cadre sur la première et la
82
+ dernière, ce qui y empilait de l'encre et gonflait la variance d'autant plus
83
+ que l'angle était grand. Le maximum tombait alors **toujours** sur la borne
84
+ de recherche — y compris pour une page parfaitement droite.
85
+
86
+ ``amplitude_deg`` et ``pas_deg`` bornent la recherche, ils ne la décident
87
+ pas. Amplitude volontairement modeste : au-delà, ce n'est plus une
88
+ inclinaison de numérisation mais une page tournée, qui relève d'une autre
89
+ opération. Image trop petite → ``0.0`` : rien à mesurer, et on ne devine pas.
90
+ """
91
+ if gray.ndim != 2 or min(gray.shape) < 16:
92
+ return 0.0
93
+ encre = 255.0 - gray.astype(np.float64)
94
+ hauteur, largeur = encre.shape
95
+ pas = max(pas_deg, 0.01)
96
+ marge = int(np.ceil(np.tan(np.radians(amplitude_deg)) * largeur)) + 1
97
+ etendue = hauteur + 2 * marge
98
+ lignes = np.arange(hauteur, dtype=np.float64)[:, None]
99
+ colonnes = np.arange(largeur, dtype=np.float64)[None, :]
100
+ meilleur_angle, meilleure_variance = 0.0, -1.0
101
+ for k in range(-int(amplitude_deg / pas), int(amplitude_deg / pas) + 1):
102
+ angle = k * pas
103
+ # Cisaillement vertical : approxime la rotation pour de petits angles,
104
+ # sans ré-échantillonner l'image — donc sans en inventer les pixels.
105
+ decal = np.tan(np.radians(angle)) * colonnes
106
+ rangs = np.rint(lignes + decal).astype(np.int64) + marge
107
+ profil = np.zeros(etendue, dtype=np.float64)
108
+ np.add.at(profil, rangs.ravel(), encre.ravel())
109
+ variance = float(profil.var())
110
+ if variance > meilleure_variance:
111
+ meilleure_variance, meilleur_angle = variance, angle
112
+ return meilleur_angle
113
+
114
+
115
+ __all__ = ["binarize", "estimate_skew", "otsu_threshold", "to_grayscale"]
cinoc/adapters/preprocess/image_prep.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``ImagePreprocessor`` — ``IMAGE → IMAGE`` (couche 5).
2
+
3
+ Le banc savait lire une image, la segmenter, la transcrire, la corriger. Il ne
4
+ savait pas la **préparer** — alors que tous les systèmes de référence ont cet
5
+ étage : OCR-D en fait une famille entière (binarisation, rognage, débruitage,
6
+ redressement, dé-gondolage), PP-Structure ouvre par un module d'orientation et
7
+ de rectification, et NDNP le pratique avant Tesseract.
8
+
9
+ Sans ce rôle, une question restait sans réponse mesurable : **restaurer avant de
10
+ lire, est-ce que ça paie ?** Un banc ne doit pas y répondre par un score de
11
+ qualité arbitraire — c'est ce qui a fait retirer la mesure de qualité d'image de
12
+ ce dépôt (D-190) — mais en comparant deux pipelines sur le seul critère qui
13
+ tranche, le taux d'erreur.
14
+
15
+ Le pool d'artefacts étant indexé par type, l'``IMAGE`` produite **remplace**
16
+ l'originale pour les étapes suivantes : c'est le comportement voulu. Une étape
17
+ qui tiendrait à l'image d'origine la nomme explicitement dans son ``inputs_from``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import io
23
+ import logging
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+
29
+ from cinoc.adapters._workspace import workspace_artifact_path
30
+ from cinoc.adapters.preprocess._ops import (
31
+ binarize,
32
+ estimate_skew,
33
+ otsu_threshold,
34
+ to_grayscale,
35
+ )
36
+ from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
37
+ from cinoc.domain.errors import AdapterStepError
38
+ from cinoc.pipeline.protocols import ParamValue
39
+ from cinoc.pipeline.run_control import RunControl
40
+ from cinoc.pipeline.types import RunContext, StepOutput
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ _VERSION = "1.0"
45
+
46
+ #: Opérations disponibles, dans l'ordre où elles ont un sens : on redresse avant
47
+ #: de binariser (une binarisation fige les pixels, une rotation les ré-interpole).
48
+ OPERATIONS: tuple[str, ...] = ("grayscale", "deskew", "binarize")
49
+
50
+
51
+ class ImagePreprocessor:
52
+ """Prépare une image avant lecture. Déterministe, local, sans modèle.
53
+
54
+ ``operations`` est une liste **séparée par des virgules** — les paramètres
55
+ d'adapter sont plats par contrat, et une liste imbriquée n'y entrerait pas.
56
+ L'ordre déclaré est respecté tel quel : c'est un choix de l'utilisateur, et
57
+ deux ordres différents sont deux pipelines différents à comparer.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ label: str,
64
+ operations: str = "deskew,binarize",
65
+ skew_amplitude_deg: float = 5.0,
66
+ ) -> None:
67
+ self._label = label
68
+ self._skew = skew_amplitude_deg
69
+ demandees = [o.strip() for o in operations.split(",") if o.strip()]
70
+ inconnues = [o for o in demandees if o not in OPERATIONS]
71
+ if inconnues:
72
+ raise AdapterStepError(
73
+ f"preprocess : opération(s) inconnue(s) {inconnues} "
74
+ f"(attendu parmi {list(OPERATIONS)})."
75
+ )
76
+ if not demandees:
77
+ raise AdapterStepError(
78
+ "preprocess : aucune opération demandée — une étape qui ne fait "
79
+ "rien fausserait la comparaison en se faisant passer pour un "
80
+ "traitement."
81
+ )
82
+ self._operations = tuple(demandees)
83
+
84
+ @property
85
+ def name(self) -> str:
86
+ return f"preprocess:{self._label}"
87
+
88
+ @property
89
+ def version(self) -> str:
90
+ return _VERSION
91
+
92
+ @property
93
+ def input_types(self) -> frozenset[ArtifactType]:
94
+ return frozenset({ArtifactType.IMAGE})
95
+
96
+ @property
97
+ def output_types(self) -> frozenset[ArtifactType]:
98
+ return frozenset({ArtifactType.IMAGE})
99
+
100
+ @property
101
+ def operations(self) -> tuple[str, ...]:
102
+ return self._operations
103
+
104
+ def execute(
105
+ self,
106
+ inputs: dict[ArtifactType, Artifact],
107
+ params: dict[str, ParamValue], # noqa: ARG002 — contrat Module
108
+ context: RunContext,
109
+ control: RunControl,
110
+ ) -> StepOutput:
111
+ control.raise_if_cancelled()
112
+ image = inputs.get(ArtifactType.IMAGE)
113
+ if image is None or image.uri is None:
114
+ raise AdapterStepError(
115
+ f"{self.name} : artefact IMAGE manquant ou sans URI."
116
+ )
117
+ source = Path(image.uri)
118
+ if not source.is_file():
119
+ raise AdapterStepError(f"{self.name} : image introuvable ({source}).")
120
+
121
+ try:
122
+ import PIL # noqa: PLC0415
123
+ from PIL import Image, UnidentifiedImageError # noqa: PLC0415
124
+ except ImportError as exc: # pragma: no cover — dépend de l'installation
125
+ raise AdapterStepError(
126
+ f"{self.name} : Pillow requis pour lire l'image (extra [images])."
127
+ ) from exc
128
+
129
+ try:
130
+ with Image.open(source) as ouverte:
131
+ tableau = _en_tableau(ouverte)
132
+ except (UnidentifiedImageError, OSError) as exc:
133
+ raise AdapterStepError(
134
+ f"{self.name} : image illisible ({source.name}) — {exc}"
135
+ ) from exc
136
+
137
+ for operation in self._operations:
138
+ control.raise_if_cancelled()
139
+ tableau, note = self._appliquer(operation, tableau, PIL)
140
+ # Journalisé plutôt que silencieux : l'angle réellement mesuré et le
141
+ # seuil réellement calculé sont ce qui distingue deux exécutions sur
142
+ # deux pages. Les taire rendrait le traitement inexplicable.
143
+ logger.info("[preprocess] %s · %s", context.document_id, note)
144
+
145
+ # PNG : la sortie d'une binarisation ne survit pas à une compression avec
146
+ # perte, et un banc ne peut pas mesurer un traitement que l'encodage a
147
+ # déjà modifié.
148
+ tampon = io.BytesIO()
149
+ Image.fromarray(tableau).save(tampon, format="PNG", optimize=True)
150
+ octets = tampon.getvalue()
151
+
152
+ cible = (
153
+ workspace_artifact_path(
154
+ context.workspace_uri, context.document_id, self._label, "png"
155
+ )
156
+ if context.workspace_uri
157
+ else source.with_name(f"{source.stem}.{self._label}.png")
158
+ )
159
+ cible.parent.mkdir(parents=True, exist_ok=True)
160
+ cible.write_bytes(octets)
161
+ return StepOutput(
162
+ artifacts={
163
+ ArtifactType.IMAGE: Artifact(
164
+ id=f"{context.document_id}:{self._label}:image",
165
+ document_id=context.document_id,
166
+ type=ArtifactType.IMAGE,
167
+ uri=str(cible),
168
+ content_hash=compute_content_hash(octets),
169
+ produced_by_step=self._label,
170
+ )
171
+ }
172
+ )
173
+
174
+ def _appliquer(
175
+ self, operation: str, matrice: np.ndarray, pil: Any
176
+ ) -> tuple[np.ndarray, str]:
177
+ """Applique une opération, et rend la note qui dit ce qu'elle a fait."""
178
+ if operation == "grayscale":
179
+ return to_grayscale(matrice), "grayscale"
180
+ if operation == "deskew":
181
+ angle = estimate_skew(to_grayscale(matrice), amplitude_deg=self._skew)
182
+ if angle == 0.0:
183
+ # Le dire : « aucune rotation appliquée » et « inclinaison
184
+ # mesurée nulle » se ressemblent dans un rapport, et ne veulent
185
+ # pas dire la même chose.
186
+ return matrice, "deskew : 0,00° (aucune inclinaison mesurée)"
187
+ tournee = pil.Image.fromarray(matrice).rotate(
188
+ -angle,
189
+ resample=pil.Image.Resampling.BICUBIC,
190
+ expand=False,
191
+ fillcolor=_fond(matrice),
192
+ )
193
+ return np.asarray(tournee), f"deskew : {angle:+.2f}°"
194
+ gris = to_grayscale(matrice)
195
+ seuil = otsu_threshold(gris)
196
+ return binarize(gris, seuil), f"binarize : seuil d'Otsu {seuil}"
197
+
198
+
199
+ def _en_tableau(ouverte: Any) -> np.ndarray:
200
+ """Image PIL → tableau numpy, en RVB ou en gris (jamais en palette)."""
201
+ converti = ouverte if ouverte.mode in {"L", "RGB"} else ouverte.convert("RGB")
202
+ return np.asarray(converti)
203
+
204
+
205
+ def _fond(matrice: np.ndarray) -> int | tuple[int, int, int]:
206
+ """Remplissage d'une rotation : le blanc du support, jamais du noir.
207
+
208
+ Combler avec du noir créerait de fausses zones d'encre dans les coins, que
209
+ la binarisation prendrait ensuite pour du texte.
210
+ """
211
+ return 255 if matrice.ndim == 2 else (255, 255, 255)
212
+
213
+
214
+ __all__ = ["OPERATIONS", "ImagePreprocessor"]
cinoc/adapters/storage/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stockage (couche 5) : état des jobs de run lancés via le web/CLI long.
2
+
3
+ En mémoire par choix : suffit au lanceur mono-worker du Space. Le journal
4
+ d'événements du ``JobStore`` porte la reprise SSE ``Last-Event-ID`` (réserve
5
+ R-10 d'ANALYSE_COUCHE_5, levée).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from cinoc.adapters.storage.history_store import (
11
+ HistoryRecord,
12
+ HistoryStore,
13
+ Regression,
14
+ )
15
+ from cinoc.adapters.storage.job_store import Job, JobState, JobStore
16
+
17
+ __all__ = [
18
+ "HistoryRecord",
19
+ "HistoryStore",
20
+ "Job",
21
+ "JobState",
22
+ "JobStore",
23
+ "Regression",
24
+ ]